diff --git a/.ai/CODE_REVIEW.md b/.ai/CODE_REVIEW.md new file mode 100644 index 0000000000..2ce60c80fd --- /dev/null +++ b/.ai/CODE_REVIEW.md @@ -0,0 +1,286 @@ +# Lighthouse Code Review Guidelines + +Code review guidelines based on patterns from Lighthouse maintainers. + +## Core Principles + +- **Correctness** over clever code +- **Clarity** through good documentation and naming +- **Safety** through proper error handling and panic avoidance +- **Maintainability** for long-term health + +## Critical: Consensus Crate (`consensus/` excluding `types/`) + +**Extra scrutiny required** - bugs here cause consensus failures. + +### Requirements + +1. **Safe Math Only** + ```rust + // NEVER + let result = a + b; + + // ALWAYS + let result = a.saturating_add(b); + // or use safe_arith crate + let result = a.safe_add(b)?; + ``` + +2. **Zero Panics** + - No `.unwrap()`, `.expect()`, array indexing `[i]` + - Return `Result` or `Option` instead + +3. **Deterministic Behavior** + - Identical results across all platforms + - No undefined behavior + +## Panic Avoidance (All Code) + +```rust +// NEVER at runtime +let value = option.unwrap(); +let item = array[1]; + +// ALWAYS +let value = option.ok_or(Error::Missing)?; +let item = array.get(1)?; + +// Only acceptable during startup for CLI/config validation +let flag = matches.get_one::("flag") + .expect("Required due to clap validation"); +``` + +## Code Clarity + +### Variable Naming +```rust +// BAD - ambiguous +let bb = ...; +let bl = ...; + +// GOOD - clear +let beacon_block = ...; +let blob = ...; +``` + +### Comments +- Explain the "why" not just the "what" +- All `TODO` comments must link to a GitHub issue +- Remove dead/commented-out code + +## Error Handling + +### Don't Silently Swallow Errors +```rust +// BAD +self.store.get_info().unwrap_or(None) + +// GOOD +self.store.get_info().unwrap_or_else(|e| { + error!(self.log, "Failed to read info"; "error" => ?e); + None +}) +``` + +### Check Return Values +Ask: "What happens if this returns `Ok(Failed)`?" Don't ignore results that might indicate failure. + +## Performance & Concurrency + +### Lock Safety +- Document lock ordering requirements +- Keep lock scopes narrow +- Seek detailed review for lock-related changes +- Use `try_read` when falling back to an alternative is acceptable +- Use blocking `read` when alternative is more expensive (e.g., state reconstruction) + +### Async Patterns +```rust +// NEVER block in async context +async fn handler() { + expensive_computation(); // blocks runtime +} + +// ALWAYS spawn blocking +async fn handler() { + tokio::task::spawn_blocking(|| expensive_computation()).await?; +} +``` + +### Rayon +- Use scoped rayon pools from beacon processor +- Avoid global thread pool (causes CPU oversubscription) + +## Review Process + +### Focus on Actionable Issues + +**Limit to 3-5 key comments.** Prioritize: +1. Correctness issues - bugs, race conditions, panics +2. Missing test coverage - especially edge cases +3. Complex logic needing documentation +4. API design concerns + +**Don't comment on:** +- Minor style issues +- Things caught by CI (formatting, linting) +- Nice-to-haves that aren't important + +### Keep Comments Natural and Minimal + +**Tone**: Natural and conversational, not robotic. + +**Good review comment:** +``` +Missing test coverage for the None blobs path. The existing test at +`store_tests.rs:2874` still provides blobs. Should add a test passing +None to verify backfill handles this correctly. +``` + +**Good follow-up after author addresses comments:** +``` +LGTM, thanks! +``` +or +``` +Thanks for the updates, looks good! +``` + +**Avoid:** +- Checklists or structured formatting (✅ Item 1 fixed...) +- Repeating what was fixed (makes it obvious it's AI-generated) +- Headers, subsections, "Summary" sections +- Verbose multi-paragraph explanations + +### Use Natural Language + +``` +BAD (prescriptive): +"This violates coding standards which strictly prohibit runtime panics." + +GOOD (conversational): +"Should we avoid `.expect()` here? This gets called in hot paths and +we typically try to avoid runtime panics outside of startup." +``` + +### Verify Before Commenting + +- If CI passes, trust it - types/imports must exist +- Check the full diff, not just visible parts +- Ask for verification rather than asserting things are missing + +## Common Review Patterns + +### Fork-Specific Changes +- Verify production fork code path unchanged +- Check SSZ compatibility (field order) +- Verify rollback/error paths handle edge cases + +### API Design +- Constructor signatures should be consistent +- Avoid `Option` parameters when value is always required + +### Concurrency +- Lock ordering documented? +- Potential deadlocks? +- Race conditions? + +### Error Handling +- Errors logged? +- Edge cases handled? +- Context provided with errors? + +## Large PR Strategy + +Large PRs (10+ files) make it easy to miss subtle bugs in individual files. + +- **Group files by subsystem** (networking, store, types, etc.) and review each group, but pay extra attention to changes that cross subsystem boundaries. +- **Review shared type/interface changes first** — changes to function signatures, return types, or struct definitions ripple through all callers. When reviewing a large PR, identify these first and trace their impact across the codebase. Downstream code may silently change behavior even if it looks untouched. +- **Flag missing test coverage for changed behavior** — if a code path's semantics change (even subtly), check that tests exercise it. If not, flag the gap. + +## Deep Review Techniques + +### Verify Against Specifications +- Read the actual spec in `./consensus-specs/` +- Compare formulas exactly +- Check constant values match spec definitions + +### Trace Data Flow End-to-End +For new config fields: +1. Config file - Does YAML contain the field? +2. Config struct - Is it parsed with serde attributes? +3. apply_to_chain_spec - Is it actually applied? +4. Runtime usage - Used correctly everywhere? + +### Check Error Handling Fallbacks +Examine every `.unwrap_or()`, `.unwrap_or_else()`: +- If the fallback triggers, does code behave correctly? +- Does it silently degrade or fail loudly? + +### Look for Incomplete Migrations +When a PR changes a pattern across the codebase: +- Search for old pattern - all occurrences updated? +- Check test files - often lag behind implementation + +## Architecture & Design + +### Avoid Dependency Bloat +- Question whether imports add unnecessary dependencies +- Consider feature flags for optional functionality +- Large imports when only primitives are needed may warrant a `core` or `primitives` feature + +### Schema Migrations +- Database schema changes require migrations +- Don't forget to add migration code when changing stored types +- Review pattern: "Needs a schema migration" + +### Backwards Compatibility +- Consider existing users when changing behavior +- Document breaking changes clearly +- Prefer additive changes when possible + +## Anti-Patterns to Avoid + +### Over-Engineering +- Don't add abstractions until needed +- Keep solutions simple and focused +- "Three similar lines of code is better than a premature abstraction" + +### Unnecessary Complexity +- Avoid feature flags for simple changes +- Don't add fallbacks for scenarios that can't happen +- Trust internal code and framework guarantees + +### Premature Optimization +- Optimize hot paths based on profiling, not assumptions +- Document performance considerations but don't over-optimize + +### Hiding Important Information +- Don't use generic variable names when specific ones are clearer +- Don't skip logging just to keep code shorter +- Don't omit error context + +## Design Principles + +### Simplicity First +Question every layer of abstraction: +- Is this `Arc` needed, or is the inner type already `Clone`? +- Is this `Mutex` needed, or can ownership be restructured? +- Is this wrapper type adding value or just indirection? + +If you can't articulate why a layer of abstraction exists, it probably shouldn't. + +### High Cohesion +Group related state and behavior together. If two fields are always set together, used together, and invalid without each other, they belong in a struct. + +## Before Approval Checklist + +- [ ] No panics: No `.unwrap()`, `.expect()`, unchecked array indexing +- [ ] Consensus safe: If touching consensus crate, all arithmetic is safe +- [ ] Errors logged: Not silently swallowed +- [ ] Clear naming: Variable names are unambiguous +- [ ] TODOs linked: All TODOs have GitHub issue links +- [ ] Tests present: Non-trivial changes have tests +- [ ] Lock safety: Lock ordering is safe and documented +- [ ] No blocking: Async code doesn't block runtime + diff --git a/.ai/DEVELOPMENT.md b/.ai/DEVELOPMENT.md new file mode 100644 index 0000000000..1204f21ead --- /dev/null +++ b/.ai/DEVELOPMENT.md @@ -0,0 +1,200 @@ +# Lighthouse Development Guide + +Development patterns, commands, and architecture for AI assistants and contributors. + +## Development Commands + +**Important**: Always branch from `unstable` and target `unstable` when creating pull requests. + +### Building + +- `make install` - Build and install Lighthouse in release mode +- `make install-lcli` - Build and install `lcli` utility +- `cargo build --release` - Standard release build +- `cargo build --bin lighthouse --features "gnosis,slasher-lmdb"` - Build with specific features + +### Testing + +- `make test` - Full test suite in release mode +- `make test-release` - Run tests using nextest (faster parallel runner) +- `cargo nextest run -p ` - Run tests for specific package (preferred for iteration) +- `cargo nextest run -p ` - Run individual test +- `FORK_NAME=electra cargo nextest run -p beacon_chain` - Run tests for specific fork +- `make test-ef` - Ethereum Foundation test vectors + +**Fork-specific testing**: `beacon_chain` and `http_api` tests support fork-specific testing via `FORK_NAME` env var when `beacon_chain/fork_from_env` feature is enabled. + +**Note**: Full test suite takes ~20 minutes. Prefer targeted tests when iterating. + +### Linting + +- `make lint` - Run Clippy with project rules +- `make lint-full` - Comprehensive linting including tests +- `cargo fmt --all && make lint-fix` - Format and fix linting issues +- `cargo sort` - Sort dependencies (enforced on CI) + +## Architecture Overview + +Lighthouse is a modular Ethereum consensus client with two main components: + +### Beacon Node (`beacon_node/`) + +- Main consensus client syncing with Ethereum network +- Beacon chain state transition logic (`beacon_node/beacon_chain/`) +- Networking, storage, P2P communication +- HTTP API for validator clients +- Entry point: `beacon_node/src/lib.rs` + +### Validator Client (`validator_client/`) + +- Manages validator keystores and duties +- Block proposals, attestations, sync committee duties +- Slashing protection and doppelganger detection +- Entry point: `validator_client/src/lib.rs` + +### Key Subsystems + +| Subsystem | Location | Purpose | +|-----------|----------|---------| +| Consensus Types | `consensus/types/` | Core data structures, SSZ encoding | +| Storage | `beacon_node/store/` | Hot/cold database (LevelDB, RocksDB, REDB backends) | +| Networking | `beacon_node/lighthouse_network/` | Libp2p, gossipsub, discovery | +| Fork Choice | `consensus/fork_choice/` | Proto-array fork choice | +| Execution Layer | `beacon_node/execution_layer/` | EL client integration | +| Slasher | `slasher/` | Optional slashing detection | + +### Utilities + +- `account_manager/` - Validator account management +- `lcli/` - Command-line debugging utilities +- `database_manager/` - Database maintenance tools + +## Code Quality Standards + +### Panic Avoidance (Critical) + +**Panics should be avoided at all costs.** + +```rust +// NEVER at runtime +let value = some_result.unwrap(); +let item = array[1]; + +// ALWAYS prefer +let value = some_result?; +let item = array.get(1)?; + +// Only acceptable during startup +let config = matches.get_one::("flag") + .expect("Required due to clap validation"); +``` + +### Consensus Crate Safety (`consensus/` excluding `types/`) + +Extra scrutiny required - bugs here cause consensus failures. + +```rust +// NEVER standard arithmetic +let result = a + b; + +// ALWAYS safe math +let result = a.saturating_add(b); +// or +use safe_arith::SafeArith; +let result = a.safe_add(b)?; +``` + +Requirements: +- Use `saturating_*` or `checked_*` operations +- Zero panics - no `.unwrap()`, `.expect()`, or `array[i]` +- Deterministic behavior across all platforms + +### Error Handling + +- Return `Result` or `Option` instead of panicking +- Log errors, don't silently swallow them +- Provide context with errors + +### Async Patterns + +```rust +// NEVER block in async context +async fn handler() { + expensive_computation(); // blocks runtime +} + +// ALWAYS spawn blocking +async fn handler() { + tokio::task::spawn_blocking(|| expensive_computation()).await?; +} +``` + +### Concurrency + +- **Lock ordering**: Document lock ordering to avoid deadlocks. See [`canonical_head.rs:9-32`](beacon_node/beacon_chain/src/canonical_head.rs) for excellent example documenting three locks and safe acquisition order. +- Keep lock scopes narrow +- Seek detailed review for lock-related changes + +### Rayon Thread Pools + +Avoid using the rayon global thread pool - it causes CPU oversubscription when beacon processor has fully allocated all CPUs to workers. Use scoped rayon pools started by beacon processor for computationally intensive tasks. + +### Tracing Spans + +- Avoid spans on simple getter methods (performance overhead) +- Be cautious of span explosion with recursive functions +- Use spans per meaningful computation step, not every function +- **Never** use `span.enter()` or `span.entered()` in async tasks + +### Documentation + +- All `TODO` comments must link to a GitHub issue +- Prefer line comments (`//`) over block comments +- Keep comments concise, explain "why" not "what" + +## Logging Levels + +| Level | Use Case | +|-------|----------| +| `crit` | Lighthouse may not function - needs immediate attention | +| `error` | Moderate impact - expect user reports | +| `warn` | Unexpected but recoverable | +| `info` | High-level status - not excessive | +| `debug` | Developer events, expected errors | + +## Testing Patterns + +- **Unit tests**: Single component edge cases +- **Integration tests**: Use [`BeaconChainHarness`](beacon_node/beacon_chain/src/test_utils.rs) for end-to-end workflows +- **Sync components**: Use [`TestRig`](beacon_node/network/src/sync/tests/mod.rs) pattern with event-based testing +- **Mocking**: `mockall` for unit tests, `mockito` for HTTP APIs +- **Adapter pattern**: For testing `BeaconChain` dependent components, create adapter structs. See [`fetch_blobs/tests.rs`](beacon_node/beacon_chain/src/fetch_blobs/tests.rs) +- **Local testnet**: See `scripts/local_testnet/README.md` + +## Build Notes + +- Full builds take 5+ minutes - use large timeouts (300s+) +- Use `cargo check` for faster iteration +- MSRV documented in `Cargo.toml` + +### Cross-compilation + +- `make build-x86_64` - Cross-compile for x86_64 Linux +- `make build-aarch64` - Cross-compile for ARM64 Linux +- `make build-riscv64` - Cross-compile for RISC-V 64-bit Linux + +## Parallel Development + +For working on multiple branches simultaneously, use git worktrees: + +```bash +git worktree add -b my-feature ../lighthouse-my-feature unstable +``` + +This creates a separate working directory without needing multiple clones. To save disk space across worktrees, configure a shared target directory: + +```bash +# In .cargo/config.toml at your workspace root +[build] +target-dir = "/path/to/shared-target" +``` diff --git a/.ai/ISSUES.md b/.ai/ISSUES.md new file mode 100644 index 0000000000..ce79198b4d --- /dev/null +++ b/.ai/ISSUES.md @@ -0,0 +1,130 @@ +# GitHub Issue & PR Guidelines + +Guidelines for creating well-structured GitHub issues and PRs for Lighthouse. + +## Issue Structure + +### Start with Description + +Always begin with `## Description`: + +```markdown +## Description + +We presently prune all knowledge of non-canonical blocks once they conflict with +finalization. The pruning is not always immediate, fork choice currently prunes +once the number of nodes reaches a threshold of 256. + +It would be nice to develop a simple system for handling messages relating to +blocks that are non-canonical. +``` + +**Guidelines:** +- First paragraph: problem and brief solution +- Provide context about current behavior +- Link to related issues, PRs, or specs +- Be technical and specific + +### Steps to Resolve (when applicable) + +```markdown +## Steps to resolve + +I see two ways to fix this: a strict approach, and a pragmatic one. + +The strict approach would only check once the slot is finalized. This would have +0 false positives, but would be slower to detect missed blocks. + +The pragmatic approach might be to only process `BeaconState`s from the canonical +chain. I don't have a strong preference between approaches. +``` + +**Guidelines:** +- Don't be overly prescriptive - present options +- Mention relevant constraints +- It's okay to say "I don't have a strong preference" + +### Optional Sections + +- `## Additional Info` - Edge cases, related issues +- `## Metrics` - Performance data, observations +- `## Version` - For bug reports + +## Code References + +**Use GitHub permalinks with commit hashes** so code renders properly: + +``` +https://github.com/sigp/lighthouse/blob/261322c3e3ee/beacon_node/beacon_processor/src/lib.rs#L809 +``` + +Get commit hash: `git rev-parse unstable` + +For line ranges: `#L809-L825` + +## Writing Style + +### Be Natural and Concise +- Direct and objective +- Precise technical terminology +- Avoid AI-sounding language + +### Be Honest About Uncertainty +- Don't guess - ask questions +- Use tentative language when appropriate ("might", "I think") +- Present multiple options without picking one + +### Think About Trade-offs +- Present multiple approaches +- Discuss pros and cons +- Consider backward compatibility +- Note performance implications + +## Labels + +**Type:** `bug`, `enhancement`, `optimization`, `code-quality`, `security`, `RFC` + +**Component:** `database`, `HTTP-API`, `fork-choice`, `beacon-processor`, etc. + +**Effort:** `good first issue`, `low-hanging-fruit`, `major-task` + +## Pull Request Guidelines + +```markdown +## Description + +[What does this PR do? Why is it needed? Be concise and technical.] + +Closes #[issue-number] + +## Additional Info + +[Breaking changes, performance impacts, migration steps, etc.] +``` + +### Commit Messages + +Format: +- First line: Brief summary (imperative mood) +- Blank line +- Additional details if needed + +``` +Add custody info API for data columns + +Implements `/lighthouse/custody/info` endpoint that returns custody group +count, custodied columns, and earliest available data column slot. +``` + +## Anti-Patterns + +- Vague descriptions without details +- No code references when describing code +- Premature solutions without understanding the problem +- Making claims without validating against codebase + +## Good Examples + +- https://github.com/sigp/lighthouse/issues/6120 +- https://github.com/sigp/lighthouse/issues/4388 +- https://github.com/sigp/lighthouse/issues/8216 diff --git a/.claude/commands/issue.md b/.claude/commands/issue.md new file mode 100644 index 0000000000..85ff46fe22 --- /dev/null +++ b/.claude/commands/issue.md @@ -0,0 +1,49 @@ +# GitHub Issue Creation Task + +You are creating a GitHub issue for the Lighthouse project. + +## Required Reading + +**Before creating an issue, read `.ai/ISSUES.md`** for issue and PR writing guidelines. + +## Structure + +1. **Description** (required) + - First paragraph: problem and brief solution + - Context about current behavior + - Links to related issues, PRs, or specs + - Technical and specific + +2. **Steps to Resolve** (when applicable) + - Present options and considerations + - Don't be overly prescriptive + - Mention relevant constraints + +3. **Code References** + - Use GitHub permalinks with commit hashes + - Get hash: `git rev-parse unstable` + +## Style + +- Natural, concise, direct +- Avoid AI-sounding language +- Be honest about uncertainty +- Present trade-offs + +## Labels to Suggest + +- **Type**: bug, enhancement, optimization, code-quality +- **Component**: database, HTTP-API, fork-choice, beacon-processor +- **Effort**: good first issue, low-hanging-fruit, major-task + +## Output + +Provide the complete issue text ready to paste into GitHub. + +## After Feedback + +If the developer refines your issue/PR text or suggests a different format: + +1. **Apply their feedback** to the current issue +2. **Offer to update docs** - Ask: "Should I update `.ai/ISSUES.md` to capture this preference?" +3. **Document patterns** the team prefers that aren't yet in the guidelines diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 0000000000..1694e90cc5 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,85 @@ +# Release Notes Generation Task + +You are generating release notes for a new Lighthouse version. + +## Input Required + +- **Version number** (e.g., v8.1.0) +- **Base branch** (typically `stable` for previous release) +- **Release branch** (e.g., `release-v8.1`) +- **Release name** (Rick and Morty character - check existing to avoid duplicates) + +## Step 1: Gather Changes + +```bash +# Get commits between branches +git log --oneline origin/..origin/ + +# Check existing release names +gh release list --repo sigp/lighthouse --limit 50 +``` + +## Step 2: Analyze PRs + +For each PR: +1. Extract PR numbers from commit messages +2. Check for `backwards-incompat` label: + ```bash + gh pr view --repo sigp/lighthouse --json labels --jq '[.labels[].name] | join(",")' + ``` +3. Get PR details for context + +## Step 3: Categorize + +Group into sections (skip empty): +- **Breaking Changes** - schema changes, CLI changes, API changes +- **Performance Improvements** - user-noticeable optimizations +- **Validator Client Improvements** - VC-specific changes +- **Other Notable Changes** - new features, metrics +- **CLI Changes** - new/changed flags (note if BN or VC) +- **Bug Fixes** - significant user-facing fixes only + +## Step 4: Write Release Notes + +```markdown +## + +## Summary + +Lighthouse v includes . + +This is a upgrade for . + +##
+ +- **** (#<PR>): <User-facing description> + +## Update Priority + +| User Class | Beacon Node | Validator Client | +|:------------------|:------------|:-----------------| +| Staking Users | Low/Medium/High | Low/Medium/High | +| Non-Staking Users | Low/Medium/High | --- | + +## All Changes + +- <commit title> (#<PR>) + +## Binaries + +[See pre-built binaries documentation.](https://lighthouse-book.sigmaprime.io/installation_binaries.html) +``` + +## Guidelines + +- State **user impact**, not implementation details +- Avoid jargon users won't understand +- For CLI flags, mention if BN or VC +- Check PR descriptions for context + +## Step 5: Generate Announcements + +Create drafts for: +- **Email** - Formal, include priority table +- **Discord** - Tag @everyone, shorter +- **Twitter** - Single tweet, 2-3 highlights diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000000..7867716c79 --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,57 @@ +# Code Review Task + +You are reviewing code for the Lighthouse project. + +## Required Reading + +**Before reviewing, read `.ai/CODE_REVIEW.md`** for Lighthouse-specific safety requirements and review etiquette. + +## Focus Areas + +1. **Consensus Crate Safety** (if applicable) + - Safe math operations (saturating_*, checked_*) + - Zero panics + - Deterministic behavior + +2. **General Code Safety** + - No `.unwrap()` or `.expect()` at runtime + - No array indexing without bounds checks + - Proper error handling + +3. **Code Clarity** + - Clear variable names (avoid ambiguous abbreviations) + - Well-documented complex logic + - TODOs linked to GitHub issues + +4. **Error Handling** + - Errors are logged, not silently swallowed + - Edge cases are handled + - Return values are checked + +5. **Concurrency & Performance** + - Lock ordering is safe + - No blocking in async context + - Proper use of rayon thread pools + +## Output + +- Keep to 3-5 actionable comments +- Use natural, conversational language +- Provide specific line references +- Ask questions rather than making demands + +## After Review Discussion + +If the developer corrects your feedback or you learn something new: + +1. **Acknowledge and learn** - Note what you got wrong +2. **Offer to update docs** - Ask: "Should I update `.ai/CODE_REVIEW.md` with this lesson?" +3. **Format the lesson:** + ```markdown + ### Lesson: [Title] + **Issue:** [What went wrong] + **Feedback:** [What developer said] + **Learning:** [What to do differently] + ``` + +This keeps the review guidelines improving over time. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..ae426dd254 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "echo '\n[Reminder] Run: cargo fmt --all && make lint-fix'" + } + ] + } + ] + } +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000000..42a5ca79e0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# Pre-commit hook: runs cargo fmt --check +# Install with: make install-hooks + +exec cargo fmt --check diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cdec442276..e9ec8740a0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ /beacon_node/network/ @jxs /beacon_node/lighthouse_network/ @jxs /beacon_node/store/ @michaelsproul +/.github/forbidden-files.txt @michaelsproul diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt new file mode 100644 index 0000000000..8649fbb574 --- /dev/null +++ b/.github/forbidden-files.txt @@ -0,0 +1,15 @@ +# Files that have been intentionally deleted and should not be re-added. +# This prevents accidentally reviving files during botched merges. +# Add one file path per line (relative to repo root). + +beacon_node/beacon_chain/src/otb_verification_service.rs +beacon_node/store/src/partial_beacon_state.rs +beacon_node/store/src/consensus_context.rs +beacon_node/beacon_chain/src/block_reward.rs +beacon_node/http_api/src/attestation_performance.rs +beacon_node/http_api/src/block_packing_efficiency.rs +beacon_node/http_api/src/block_rewards.rs +common/eth2/src/lighthouse/attestation_performance.rs +common/eth2/src/lighthouse/block_packing_efficiency.rs +common/eth2/src/lighthouse/block_rewards.rs +consensus/types/src/execution/state_payload_status.rs diff --git a/.github/workflows/docker-reproducible.yml b/.github/workflows/docker-reproducible.yml index f3479e9468..7e46fc691b 100644 --- a/.github/workflows/docker-reproducible.yml +++ b/.github/workflows/docker-reproducible.yml @@ -4,7 +4,6 @@ on: push: branches: - unstable - - stable tags: - v* workflow_dispatch: # allows manual triggering for testing purposes and skips publishing an image @@ -25,9 +24,6 @@ jobs: if [[ "${{ github.ref }}" == refs/tags/* ]]; then # It's a tag (e.g., v1.2.3) VERSION="${GITHUB_REF#refs/tags/}" - elif [[ "${{ github.ref }}" == refs/heads/stable ]]; then - # stable branch -> latest - VERSION="latest" elif [[ "${{ github.ref }}" == refs/heads/unstable ]]; then # unstable branch -> latest-unstable VERSION="latest-unstable" @@ -174,3 +170,14 @@ jobs: ${IMAGE_NAME}:${VERSION}-arm64 docker manifest push ${IMAGE_NAME}:${VERSION} + + # For version tags, also create/update the latest tag to keep stable up to date + # Only create latest tag for proper release versions (e.g. v1.2.3, not v1.2.3-alpha) + if [[ "${GITHUB_REF}" == refs/tags/* ]] && [[ "${VERSION}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$ ]]; then + docker manifest create \ + ${IMAGE_NAME}:latest \ + ${IMAGE_NAME}:${VERSION}-amd64 \ + ${IMAGE_NAME}:${VERSION}-arm64 + + docker manifest push ${IMAGE_NAME}:latest + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 415f4db0e6..e3f6e5d8b8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,7 +4,6 @@ on: push: branches: - unstable - - stable tags: - v* @@ -28,11 +27,6 @@ jobs: extract-version: runs-on: ubuntu-22.04 steps: - - name: Extract version (if stable) - if: github.event.ref == 'refs/heads/stable' - run: | - echo "VERSION=latest" >> $GITHUB_ENV - echo "VERSION_SUFFIX=" >> $GITHUB_ENV - name: Extract version (if unstable) if: github.event.ref == 'refs/heads/unstable' run: | @@ -159,7 +153,16 @@ jobs: - name: Create and push multiarch manifests run: | + # Create the main tag (versioned for releases, latest-unstable for unstable) docker buildx imagetools create -t ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}${VERSION_SUFFIX} \ ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-arm64${VERSION_SUFFIX} \ ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-amd64${VERSION_SUFFIX}; + # For version tags, also create/update the latest tag to keep stable up to date + # Only create latest tag for proper release versions (e.g. v1.2.3, not v1.2.3-alpha) + if [[ "${GITHUB_REF}" == refs/tags/* ]] && [[ "${VERSION}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$ ]]; then + docker buildx imagetools create -t ${{ github.repository_owner}}/${{ matrix.binary }}:latest \ + ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-arm64${VERSION_SUFFIX} \ + ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-amd64${VERSION_SUFFIX}; + fi + diff --git a/.github/workflows/local-testnet.yml b/.github/workflows/local-testnet.yml index 9992273e0a..308ddcf819 100644 --- a/.github/workflows/local-testnet.yml +++ b/.github/workflows/local-testnet.yml @@ -38,7 +38,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -106,7 +106,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -142,7 +142,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -185,7 +185,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -227,7 +227,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable diff --git a/.github/workflows/nightly-tests.yml b/.github/workflows/nightly-tests.yml index be52c5b84d..636d0ea0dd 100644 --- a/.github/workflows/nightly-tests.yml +++ b/.github/workflows/nightly-tests.yml @@ -6,6 +6,11 @@ on: # Run at 8:30 AM UTC every day - cron: '30 8 * * *' workflow_dispatch: # Allow manual triggering + inputs: + branch: + description: 'Branch to test' + required: false + default: 'unstable' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -47,6 +52,8 @@ jobs: fail-fast: false steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.branch || 'unstable' }} - name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: @@ -57,28 +64,6 @@ jobs: run: make test-beacon-chain-${{ matrix.fork }} timeout-minutes: 60 - http-api-tests: - name: http-api-tests - needs: setup-matrix - runs-on: 'ubuntu-latest' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - strategy: - matrix: - fork: ${{ fromJson(needs.setup-matrix.outputs.forks) }} - fail-fast: false - steps: - - uses: actions/checkout@v5 - - name: Get latest version of stable Rust - uses: moonrepo/setup-rust@v1 - with: - channel: stable - cache-target: release - bins: cargo-nextest - - name: Run http_api tests for ${{ matrix.fork }} - run: make test-http-api-${{ matrix.fork }} - timeout-minutes: 60 - op-pool-tests: name: op-pool-tests needs: setup-matrix @@ -91,6 +76,8 @@ jobs: fail-fast: false steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.branch || 'unstable' }} - name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: @@ -113,6 +100,8 @@ jobs: fail-fast: false steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.branch || 'unstable' }} - name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 7344a9367b..c2ce6f89be 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -72,6 +72,27 @@ jobs: steps: - name: Check that the pull request is not targeting the stable branch run: test ${{ github.base_ref }} != "stable" + + forbidden-files-check: + name: forbidden-files-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for forbidden files + run: | + if [ -f .github/forbidden-files.txt ]; then + status=0 + while IFS= read -r file || [ -n "$file" ]; do + # Skip comments and empty lines + [[ "$file" =~ ^#.*$ || -z "$file" ]] && continue + if [ -f "$file" ]; then + echo "::error::Forbidden file exists: $file" + status=1 + fi + done < .github/forbidden-files.txt + exit $status + fi + release-tests-ubuntu: name: release-tests-ubuntu needs: [check-labels] @@ -92,10 +113,6 @@ jobs: bins: cargo-nextest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Install Foundry (anvil) - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly-ca67d15f4abd46394b324c50e21e66f306a1162d - name: Run tests in release run: make test-release - name: Show cache stats @@ -213,10 +230,6 @@ jobs: with: channel: stable bins: cargo-nextest - - name: Install Foundry (anvil) - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly-ca67d15f4abd46394b324c50e21e66f306a1162d - name: Run tests in debug run: make test-debug state-transition-vectors-ubuntu: @@ -327,6 +340,8 @@ jobs: bins: cargo-audit,cargo-deny - name: Check formatting with cargo fmt run: make cargo-fmt + - name: Check dependencies for unencrypted HTTP links + run: make insecure-deps - name: Lint code for quality and style with Clippy run: make lint-full - name: Certify Cargo.lock freshness @@ -412,6 +427,22 @@ jobs: cache-target: release - name: Run Makefile to trigger the bash script run: make cli-local + cargo-hack: + name: cargo-hack + needs: [check-labels] + if: needs.check-labels.outputs.skip_ci != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Get latest version of stable Rust + uses: moonrepo/setup-rust@v1 + with: + channel: stable + - uses: taiki-e/install-action@cargo-hack + - name: Check types feature powerset + run: cargo hack check -p types --feature-powerset --no-dev-deps --exclude-features arbitrary-fuzz,portable + - name: Check eth2 feature powerset + run: cargo hack check -p eth2 --feature-powerset --no-dev-deps cargo-sort: name: cargo-sort needs: [check-labels] @@ -436,6 +467,7 @@ jobs: needs: [ 'check-labels', 'target-branch-check', + 'forbidden-files-check', 'release-tests-ubuntu', 'beacon-chain-tests', 'op-pool-tests', @@ -454,6 +486,7 @@ jobs: 'compile-with-beta-compiler', 'cli-check', 'lockbud', + 'cargo-hack', 'cargo-sort', ] steps: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..4ab3ec9333 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Lighthouse AI Assistant Guide + +See [`CLAUDE.md`](CLAUDE.md) for AI assistant guidance. + +This file exists for OpenAI Codex compatibility. Codex can read files, so refer to `CLAUDE.md` for the full documentation including: + +- Quick reference commands +- Critical rules (panics, safe math, async) +- Project structure +- Pointers to detailed guides in `.ai/` diff --git a/CLAUDE.md b/CLAUDE.md index 3e9ab169f3..34a895f464 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,332 +1,158 @@ -# CLAUDE.md +# Lighthouse AI Assistant Guide -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance for AI assistants (Claude Code, Codex, etc.) working with Lighthouse. -## Development Commands +## CRITICAL - Always Follow -**Important**: Always branch from `unstable` and target `unstable` when creating pull requests. +After completing ANY code changes: +1. **MUST** run `cargo check` to verify compilation before considering task complete -### Building and Installation +Run `make install-hooks` if you have not already to install git hooks. Never skip git hooks. If cargo is not available install the toolchain. -- `make install` - Build and install the main Lighthouse binary in release mode -- `make install-lcli` - Build and install the `lcli` utility binary -- `cargo build --release` - Standard Rust release build -- `cargo build --bin lighthouse --features "gnosis,slasher-lmdb"` - Build with specific features +## Quick Reference -### Testing +```bash +# Build +make install # Build and install Lighthouse +cargo build --release # Standard release build -- `make test` - Run the full test suite in release mode (excludes EF tests, beacon_chain, slasher, network, http_api) -- `make test-release` - Run tests using nextest (faster parallel test runner) -- `make test-beacon-chain` - Run beacon chain tests for all supported forks -- `make test-slasher` - Run slasher tests with all database backend combinations -- `make test-ef` - Download and run Ethereum Foundation test vectors -- `make test-full` - Complete test suite including linting, EF tests, and execution engine tests -- `cargo nextest run -p <package_name>` - Run tests for a specific package -- `cargo nextest run -p <package_name> <test_name>` - Run individual test (preferred during development iteration) -- `FORK_NAME=electra cargo nextest run -p beacon_chain` - Run tests for specific fork +# Test (prefer targeted tests when iterating) +cargo nextest run -p <package> # Test specific package +cargo nextest run -p <package> <test> # Run individual test +make test # Full test suite (~20 min) -**Note**: Full test suite takes ~20 minutes. When iterating, prefer running individual tests. - -### Linting and Code Quality - -- `make lint` - Run Clippy linter with project-specific rules -- `make lint-full` - Run comprehensive linting including tests (recommended for thorough checking) -- `make cargo-fmt` - Check code formatting with rustfmt -- `make check-benches` - Typecheck benchmark code -- `make audit` - Run security audit on dependencies - -### Cross-compilation - -- `make build-x86_64` - Cross-compile for x86_64 Linux -- `make build-aarch64` - Cross-compile for ARM64 Linux -- `make build-riscv64` - Cross-compile for RISC-V 64-bit Linux - -## Architecture Overview - -Lighthouse is a modular Ethereum consensus client with two main components: - -### Core Components - -**Beacon Node** (`beacon_node/`) - -- Main consensus client that syncs with the Ethereum network -- Contains the beacon chain state transition logic (`beacon_node/beacon_chain/`) -- Handles networking, storage, and P2P communication -- Provides HTTP API for validator clients and external tools -- Entry point: `beacon_node/src/lib.rs` - -**Validator Client** (`validator_client/`) - -- Manages validator keystores and performs validator duties -- Connects to beacon nodes via HTTP API -- Handles block proposals, attestations, and sync committee duties -- Includes slashing protection and doppelganger detection -- Entry point: `validator_client/src/lib.rs` - -### Key Subsystems - -**Consensus Types** (`consensus/types/`) - -- Core Ethereum consensus data structures (BeaconState, BeaconBlock, etc.) -- Ethereum specification implementations for different networks (mainnet, gnosis) -- SSZ encoding/decoding and state transition primitives - -**Storage** (`beacon_node/store/`) - -- Hot/cold database architecture for efficient beacon chain storage -- Supports multiple backends (LevelDB, RocksDB, REDB) -- Handles state pruning and historical data management - -**Networking** (`beacon_node/lighthouse_network/`, `beacon_node/network/`) - -- Libp2p-based P2P networking stack -- Gossipsub for message propagation -- Discovery v5 for peer discovery -- Request/response protocols for sync - -**Fork Choice** (`consensus/fork_choice/`, `consensus/proto_array/`) - -- Implements Ethereum's fork choice algorithm (proto-array) -- Manages chain reorganizations and finality - -**Execution Layer Integration** (`beacon_node/execution_layer/`) - -- Interfaces with execution clients -- Retrieves payloads from local execution layer or external block builders -- Handles payload validation and builder integration - -**Slasher** (`slasher/`) - -- Optional slashing detection service -- Supports LMDB, MDBX, and REDB database backends -- Can be enabled with `--slasher` flag - -### Utilities - -**Account Manager** (`account_manager/`) - CLI tool for managing validator accounts and keystores -**LCLI** (`lcli/`) - Lighthouse command-line utilities for debugging and testing -**Database Manager** (`database_manager/`) - Database maintenance and migration tools - -### Build System Notes - -- Uses Cargo workspace with 90+ member crates -- Supports multiple Ethereum specifications via feature flags (`gnosis`, `spec-minimal`) -- Cross-compilation support for Linux x86_64, ARM64, and RISC-V -- Multiple build profiles: `release`, `maxperf`, `reproducible` -- Feature-based compilation for different database backends and optional components - -### Network Support - -- **Mainnet**: Default production network -- **Gnosis**: Alternative network (requires `gnosis` feature) -- **Testnets**: Holesky, Sepolia via built-in network configs -- **Custom networks**: Via `--testnet-dir` flag - -### Key Configuration - -- Default data directory: `~/.lighthouse/{network}` -- Beacon node data: `~/.lighthouse/{network}/beacon` -- Validator data: `~/.lighthouse/{network}/validators` -- Configuration primarily via CLI flags and YAML files - -## Common Review Standards - -### CI/Testing Requirements - -- All checks must pass before merge -- Test coverage expected for significant changes -- Flaky tests are actively addressed and fixed -- New features often require corresponding tests -- `beacon_chain` and `http_api` tests support fork-specific testing using `FORK_NAME` env var when `beacon_chain/fork_from_env` feature is enabled - -### Code Quality Standards - -- Clippy warnings must be fixed promptly (multiple PRs show this pattern) -- Code formatting with `cargo fmt` enforced -- Must run `cargo sort` when adding dependencies - dependency order is enforced on CI -- Performance considerations for hot paths - -### Documentation and Context - -- PRs require clear descriptions of what and why -- Breaking changes need migration documentation -- API changes require documentation updates -- When CLI is updated, run `make cli-local` to generate updated help text in lighthouse book -- Comments appreciated for complex logic - -### Security and Safety - -- Careful review of consensus-critical code paths -- Error handling patterns must be comprehensive -- Input validation for external data - -## Development Patterns and Best Practices - -### Panics and Error Handling - -- **Panics should be avoided at all costs** -- Always prefer returning a `Result` or `Option` over causing a panic (e.g., prefer `array.get(1)?` over `array[1]`) -- Avoid `expect` or `unwrap` at runtime - only acceptable during startup when validating CLI flags or configurations -- If you must make assumptions about panics, use `.expect("Helpful message")` instead of `.unwrap()` and provide detailed reasoning in nearby comments -- Use proper error handling with `Result` types and graceful error propagation - -### Rayon Usage - -- Avoid using the rayon global thread pool as it results in CPU oversubscription when the beacon processor has fully allocated all CPUs to workers -- Use scoped rayon pools started by beacon processor for computational intensive tasks - -### Locks - -- Take great care to avoid deadlocks when working with fork choice locks - seek detailed review ([reference](beacon_node/beacon_chain/src/canonical_head.rs:9)) -- Keep lock scopes as narrow as possible to avoid blocking fast-responding functions like the networking stack - -### Async Patterns - -- Avoid blocking computations in async tasks -- Spawn a blocking task instead for CPU-intensive work - -### Tracing - -- Design spans carefully and avoid overuse of spans just to add context data to events -- Avoid using spans on simple getter methods as it can result in performance overhead -- Be cautious of span explosion with recursive functions -- Use spans per meaningful step or computationally critical step -- Avoid using `span.enter()` or `span.entered()` in async tasks - -### Database - -- Maintain schema continuity on `unstable` branch -- Database migrations must be backward compatible - -### Consensus Crate - -- Use safe math methods like `saturating_xxx` or `checked_xxx` -- Critical that this crate behaves deterministically and MUST not have undefined behavior - -### Testing Patterns - -- **Use appropriate test types for the right scenarios**: - - **Unit tests** for single component edge cases and isolated logic - - **Integration tests** using [`BeaconChainHarness`](beacon_node/beacon_chain/src/test_utils.rs:668) for end-to-end workflows -- **`BeaconChainHarness` guidelines**: - - Excellent for integration testing but slower than unit tests - - Prefer unit tests instead for testing edge cases of single components - - Reserve for testing component interactions and full workflows -- **Mocking strategies**: - - Use `mockall` crate for unit test mocking - - Use `mockito` for HTTP API mocking (see [`validator_test_rig`](testing/validator_test_rig/src/mock_beacon_node.rs:20) for examples) -- **Event-based testing for sync components**: - - Use [`TestRig`](beacon_node/network/src/sync/tests/mod.rs) pattern for testing sync components - - Sync components interact with the network and beacon chain via events (their public API), making event-based testing more suitable than using internal functions and mutating internal states - - Enables testing of complex state transitions and timing-sensitive scenarios -- **Testing `BeaconChain` dependent components**: - - `BeaconChain` is difficult to create for TDD - - Create intermediate adapter structs to enable easy mocking - - See [`beacon_node/beacon_chain/src/fetch_blobs/tests.rs`](beacon_node/beacon_chain/src/fetch_blobs/tests.rs) for the adapter pattern -- **Local testnet for manual/full E2E testing**: - - Use Kurtosis-based local testnet setup for comprehensive testing - - See [`scripts/local_testnet/README.md`](scripts/local_testnet/README.md) for setup instructions - -### TODOs and Comments - -- All `TODO` statements must be accompanied by a GitHub issue link -- Prefer line (`//`) comments to block comments (`/* ... */`) -- Use doc comments (`///`) before attributes for public items -- Keep documentation concise and clear - avoid verbose explanations -- Provide examples in doc comments for public APIs when helpful - -## Logging Guidelines - -Use appropriate log levels for different scenarios: - -- **`crit`**: Critical issues with major impact to Lighthouse functionality - Lighthouse may not function correctly without resolving. Needs immediate attention. -- **`error`**: Error cases that may have moderate impact to Lighthouse functionality. Expect to receive reports from users for this level. -- **`warn`**: Unexpected code paths that don't have major impact - fully recoverable. Expect user reports if excessive warning logs occur. -- **`info`**: High-level logs indicating beacon node status and block import status. Should not be used excessively. -- **`debug`**: Events lower level than info useful for developers. Can also log errors expected during normal operation that users don't need to action. - -## Code Examples - -### Safe Math in Consensus Crate - -```rust -// ❌ Avoid - could panic -let result = a + b; - -// ✅ Preferred -let result = a.saturating_add(b); -// or -use safe_arith::SafeArith; - -let result = a.safe_add(b)?; +# Lint +make lint # Run Clippy +cargo fmt --all && make lint-fix # Format and fix ``` -### Panics and Error Handling +## Before You Start + +Read the relevant guide for your task: + +| Task | Read This First | +|------|-----------------| +| **Code review** | `.ai/CODE_REVIEW.md` | +| **Creating issues/PRs** | `.ai/ISSUES.md` | +| **Development patterns** | `.ai/DEVELOPMENT.md` | + +## Critical Rules (consensus failures or crashes) + +### 1. No Panics at Runtime ```rust -// ❌ Avoid - could panic at runtime -let value = some_result.unwrap(); +// NEVER +let value = option.unwrap(); let item = array[1]; -// ✅ Preferred - proper error handling -let value = some_result.map_err(|e| CustomError::SomeVariant(e))?; +// ALWAYS +let value = option?; let item = array.get(1)?; - -// ✅ Acceptable during startup for CLI/config validation -let config_value = matches.get_one::<String>("required-flag") - .expect("Required flag must be present due to clap validation"); - -// ✅ If you must make runtime assumptions, use expect with explanation -let item = array.get(1).expect("Array always has at least 2 elements due to validation in constructor"); -// Detailed reasoning should be provided in nearby comments ``` -### TODO Format +Only acceptable during startup for CLI/config validation. + +### 2. Consensus Crate: Safe Math Only + +In `consensus/` (excluding `types/`), use saturating or checked arithmetic: ```rust -pub fn my_function(&mut self, _something: &[u8]) -> Result<String, Error> { - // TODO: Implement proper validation here - // https://github.com/sigp/lighthouse/issues/1234 -} +// NEVER +let result = a + b; + +// ALWAYS +let result = a.saturating_add(b); ``` -### Async Task Spawning for Blocking Work +## Important Rules (bugs or performance issues) + +### 3. Never Block Async ```rust -// ❌ Avoid - blocking in async context -async fn some_handler() { - let result = expensive_computation(); // blocks async runtime -} +// NEVER +async fn handler() { expensive_computation(); } -// ✅ Preferred -async fn some_handler() { - let result = tokio::task::spawn_blocking(|| { - expensive_computation() - }).await?; +// ALWAYS +async fn handler() { + tokio::task::spawn_blocking(|| expensive_computation()).await?; } ``` -### Tracing Span Usage +### 4. Lock Ordering -```rust -// ❌ Avoid - span on simple getter -#[instrument] -fn get_head_block_root(&self) -> Hash256 { - self.head_block_root -} +Document lock ordering to avoid deadlocks. See [`canonical_head.rs:9-32`](beacon_node/beacon_chain/src/canonical_head.rs) for the pattern. -// ✅ Preferred - span on meaningful operations -#[instrument(skip(self))] -async fn process_block(&self, block: Block) -> Result<(), Error> { - // meaningful computation -} +### 5. Rayon Thread Pools + +Use scoped rayon pools from beacon processor, not global pool. Global pool causes CPU oversubscription when beacon processor has allocated all CPUs. + +## Good Practices + +### 6. TODOs Need Issues + +All `TODO` comments must link to a GitHub issue. + +### 7. Clear Variable Names + +Avoid ambiguous abbreviations (`bb`, `bl`). Use `beacon_block`, `blob`. + +## Branch & PR Guidelines + +- Branch from `unstable`, target `unstable` for PRs +- Run `cargo sort` when adding dependencies +- Run `make cli-local` when updating CLI flags + +## Project Structure + +``` +beacon_node/ # Consensus client + beacon_chain/ # State transition logic + store/ # Database (hot/cold) + network/ # P2P networking + execution_layer/ # EL integration +validator_client/ # Validator duties +consensus/ + types/ # Core data structures + fork_choice/ # Proto-array ``` -## Build and Development Notes +See `.ai/DEVELOPMENT.md` for detailed architecture. -- Full builds and tests take 5+ minutes - use large timeouts (300s+) for any `cargo build`, `cargo nextest`, or `make` commands -- Use `cargo check` for faster iteration during development and always run after code changes -- Prefer targeted package tests (`cargo nextest run -p <package>`) and individual tests over full test suite when debugging specific issues -- Use `cargo fmt --all && make lint-fix` to format code and fix linting issues once a task is complete -- Always understand the broader codebase patterns before making changes -- Minimum Supported Rust Version (MSRV) is documented in `lighthouse/Cargo.toml` - ensure Rust version meets or exceeds this requirement +## Maintaining These Docs + +**These AI docs should evolve based on real interactions.** + +### After Code Reviews + +If a developer corrects your review feedback or points out something you missed: +- Ask: "Should I update `.ai/CODE_REVIEW.md` with this lesson?" +- Add to the "Common Review Patterns" or create a new "Lessons Learned" entry +- Include: what went wrong, what the feedback was, what to do differently + +### After PR/Issue Creation + +If a developer refines your PR description or issue format: +- Ask: "Should I update `.ai/ISSUES.md` to capture this?" +- Document the preferred style or format + +### After Development Work + +If you learn something about the codebase architecture or patterns: +- Ask: "Should I update `.ai/DEVELOPMENT.md` with this?" +- Add to relevant section or create new patterns + +### Format for Lessons + +```markdown +### Lesson: [Brief Title] + +**Context:** [What task were you doing?] +**Issue:** [What went wrong or was corrected?] +**Learning:** [What to do differently next time] +``` + +### When NOT to Update + +- Minor preference differences (not worth documenting) +- One-off edge cases unlikely to recur +- Already covered by existing documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cad219c89..f81f75cd8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,15 @@ Requests](https://github.com/sigp/lighthouse/pulls) is where code gets reviewed. We use [discord](https://discord.gg/cyAszAh) to chat informally. +### A Note on LLM usage + +We are happy to support contributors who are genuinely engaging with the code base. Our general policy regarding LLM usage: + +- Please refrain from submissions that you haven't thoroughly understood, reviewed, and tested. +- Please disclose if a significant portion of your contribution was AI-generated. +- Descriptions and comments should be made by you. +- We reserve the right to reject any contributions we feel are violating the spirit of open source contribution. + ### General Work-Flow We recommend the following work-flow for contributors: diff --git a/Cargo.lock b/Cargo.lock index 7fc1459c42..e2b4860d72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.0.1" +version = "8.1.3" dependencies = [ "account_utils", "bls", @@ -42,10 +42,10 @@ dependencies = [ "regex", "rpassword", "serde", - "serde_yaml", "tracing", "types", "validator_dir", + "yaml_serde", "zeroize", ] @@ -65,19 +65,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "aes" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" -dependencies = [ - "cfg-if", - "cipher 0.3.0", - "cpufeatures", - "ctr 0.8.0", - "opaque-debug", -] - [[package]] name = "aes" version = "0.8.4" @@ -85,7 +72,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures", ] @@ -96,9 +83,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes 0.8.4", - "cipher 0.4.4", - "ctr 0.9.2", + "aes", + "cipher", + "ctr", "ghash", "subtle", ] @@ -124,6 +111,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -132,9 +128,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy-chains" -version = "0.2.20" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bc32535569185cbcb6ad5fa64d989a47bccb9a08e27284b1f2a3ccf16e6d010" +checksum = "35d744058a9daa51a8cf22a3009607498fcf82d3cf4c5444dd8056cdf651f471" dependencies = [ "alloy-primitives", "num_enum", @@ -156,7 +152,7 @@ dependencies = [ "auto_impl", "borsh", "c-kzg", - "derive_more 2.0.1", + "derive_more 2.1.0", "either", "k256", "once_cell", @@ -184,9 +180,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdff496dd4e98a81f4861e66f7eaf5f2488971848bb42d9c892f871730245c8" +checksum = "f3b1db3281bcaf03cfadb9d125fac55603526cc1d0577da555dc6184f5188f6f" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -249,19 +245,19 @@ dependencies = [ "auto_impl", "borsh", "c-kzg", - "derive_more 2.0.1", + "derive_more 2.1.0", "either", "serde", "serde_with", - "sha2 0.10.9", + "sha2", "thiserror 2.0.17", ] [[package]] name = "alloy-json-abi" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5513d5e6bd1cba6bdcf5373470f559f320c05c8c59493b6e98912fbe6733943f" +checksum = "6bfca3dbbcb7498f0f60e67aff2ad6aff57032e22eb2fd03189854be11a22c03" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -277,7 +273,7 @@ checksum = "f72cf87cda808e593381fb9f005ffa4d2475552b7a6c5ac33d087bf77d82abd0" dependencies = [ "alloy-primitives", "alloy-sol-types", - "http 1.3.1", + "http 1.4.0", "serde", "serde_json", "thiserror 2.0.17", @@ -303,7 +299,7 @@ dependencies = [ "alloy-sol-types", "async-trait", "auto_impl", - "derive_more 2.0.1", + "derive_more 2.1.0", "futures-utils-wasm", "serde", "serde_json", @@ -325,20 +321,20 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355bf68a433e0fd7f7d33d5a9fc2583fde70bf5c530f63b80845f8da5505cf28" +checksum = "5c850e6ccbd34b8a463a1e934ffc8fc00e1efc5e5489f2ad82d7797949f3bd4e" dependencies = [ "alloy-rlp", "arbitrary", "bytes", "cfg-if", "const-hex", - "derive_more 2.0.1", + "derive_more 2.1.0", "foldhash 0.2.0", "getrandom 0.3.4", - "hashbrown 0.16.0", - "indexmap 2.12.0", + "hashbrown 0.16.1", + "indexmap 2.12.1", "itoa", "k256", "keccak-asm", @@ -346,6 +342,7 @@ dependencies = [ "proptest", "proptest-derive", "rand 0.9.2", + "rapidhash", "ruint", "rustc-hash 2.1.1", "serde", @@ -411,7 +408,7 @@ checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -513,41 +510,41 @@ dependencies = [ [[package]] name = "alloy-sol-macro" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ce480400051b5217f19d6e9a82d9010cdde20f1ae9c00d53591e4a1afbb312" +checksum = "b2218e3aeb3ee665d117fdf188db0d5acfdc3f7b7502c827421cb78f26a2aec0" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d792e205ed3b72f795a8044c52877d2e6b6e9b1d13f431478121d8d4eaa9028" +checksum = "b231cb8cc48e66dd1c6e11a1402f3ac86c3667cbc13a6969a0ac030ba7bb8c88" dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.12.0", + "indexmap 2.12.1", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "syn-solidity", "tiny-keccak", ] [[package]] name = "alloy-sol-macro-input" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd1247a8f90b465ef3f1207627547ec16940c35597875cdc09c49d58b19693c" +checksum = "49a522d79929c1bf0152b07567a38f7eaed3ab149e53e7528afa78ff11994668" dependencies = [ "const-hex", "dunce", @@ -555,15 +552,15 @@ dependencies = [ "macro-string", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "954d1b2533b9b2c7959652df3076954ecb1122a28cc740aa84e7b0a49f6ac0a9" +checksum = "0475c459859c8d9428af6ff3736614655a57efda8cc435a3b8b4796fa5ac1dd0" dependencies = [ "serde", "winnow", @@ -571,9 +568,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70319350969a3af119da6fb3e9bddb1bce66c9ea933600cb297c8b1850ad2a3c" +checksum = "35287d9d821d5f26011bcd8d9101340898f761c9933cf50fca689bb7ed62fdeb" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -590,7 +587,7 @@ dependencies = [ "alloy-json-rpc", "auto_impl", "base64 0.22.1", - "derive_more 2.0.1", + "derive_more 2.1.0", "futures", "futures-utils-wasm", "parking_lot", @@ -628,7 +625,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "arrayvec", - "derive_more 2.0.1", + "derive_more 2.1.0", "nybbles", "serde", "smallvec", @@ -644,7 +641,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -827,7 +824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -865,7 +862,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -976,7 +973,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -988,7 +985,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1043,7 +1040,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -1067,7 +1064,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1078,7 +1075,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1107,7 +1104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64 0.22.1", - "http 1.3.1", + "http 1.4.0", "log", "url", ] @@ -1120,7 +1117,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1139,7 +1136,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "itoa", @@ -1165,7 +1162,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", @@ -1218,9 +1215,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "beacon_chain" @@ -1244,9 +1241,8 @@ dependencies = [ "genesis", "hex", "int_to_bytes", - "itertools 0.10.5", + "itertools 0.14.0", "kzg", - "lighthouse_tracing", "lighthouse_version", "logging", "lru 0.12.5", @@ -1284,12 +1280,12 @@ dependencies = [ "tree_hash_derive", "typenum", "types", - "zstd 0.13.3", + "zstd", ] [[package]] name = "beacon_node" -version = "8.0.1" +version = "8.1.3" dependencies = [ "account_utils", "beacon_chain", @@ -1328,7 +1324,7 @@ dependencies = [ "clap", "eth2", "futures", - "itertools 0.10.5", + "itertools 0.14.0", "sensitive_url", "serde", "slot_clock", @@ -1347,7 +1343,7 @@ version = "0.1.0" dependencies = [ "fnv", "futures", - "itertools 0.10.5", + "itertools 0.14.0", "lighthouse_network", "logging", "metrics", @@ -1384,15 +1380,32 @@ dependencies = [ "itertools 0.12.1", "lazy_static", "lazycell", - "log", - "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.110", - "which", + "syn 2.0.117", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.117", ] [[package]] @@ -1412,15 +1425,15 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-io" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative", @@ -1461,18 +1474,18 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.9.0" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ "generic-array", ] @@ -1535,7 +1548,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.0.1" +version = "8.1.3" dependencies = [ "beacon_node", "bytes", @@ -1557,9 +1570,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" dependencies = [ "borsh-derive", "cfg_aliases", @@ -1567,15 +1580,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1612,9 +1625,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byte-slice-cast" @@ -1630,33 +1643,13 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "c-kzg" version = "2.1.5" @@ -1674,9 +1667,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -1711,10 +1704,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] -name = "cc" -version = "1.2.46" +name = "cbc" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -1750,7 +1752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher 0.4.4", + "cipher", "cpufeatures", ] @@ -1762,7 +1764,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - "cipher 0.4.4", + "cipher", "poly1305", "zeroize", ] @@ -1808,15 +1810,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" -dependencies = [ - "generic-array", -] - [[package]] name = "cipher" version = "0.4.4" @@ -1858,7 +1851,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", "terminal_size", ] @@ -1871,7 +1864,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1892,8 +1885,8 @@ dependencies = [ "hex", "serde", "serde_json", - "serde_yaml", "types", + "yaml_serde", ] [[package]] @@ -1924,7 +1917,6 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "serde_yaml", "slasher", "slasher_service", "slot_clock", @@ -1937,17 +1929,30 @@ dependencies = [ "tracing", "tracing-subscriber", "types", + "yaml_serde", ] [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1965,9 +1970,9 @@ dependencies = [ [[package]] name = "compare_fields" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05162add7c8618791829528194a271dca93f69194d35b19db1ca7fbfb8275278" +checksum = "f6f45d0b4d61b582303179fb7a1a142bc9d647b7583db3b0d5f25a21d286fab9" dependencies = [ "compare_fields_derive", "itertools 0.14.0", @@ -1975,12 +1980,12 @@ dependencies = [ [[package]] name = "compare_fields_derive" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ee468b2e568b668e2a686112935e7bbe9a81bf4fa6b9f6fc3410ea45fb7ce" +checksum = "92ff1dbbda10d495b2c92749c002b2025e0be98f42d1741ecc9ff820d2f04dce" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2075,17 +2080,11 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "context_deserialize" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5f9ea0a0ae2de4943f5ca71590b6dbd0b952475f0a0cafb30a470cec78c8b9" +checksum = "4c523eea4af094b5970c321f4604abc42c5549d3cbae332e98325403fbbdbf70" dependencies = [ "context_deserialize_derive", "serde", @@ -2093,12 +2092,12 @@ dependencies = [ [[package]] name = "context_deserialize_derive" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c57b2db1e4e3ed804dcc49894a144b68fe6c754b8f545eb1dda7ad3c7dbe7e6" +checksum = "3b7bf98c48ffa511b14bb3c76202c24a8742cea1efa9570391c5d41373419a09" dependencies = [ "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2107,6 +2106,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2153,9 +2161,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -2177,25 +2185,24 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -2203,12 +2210,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -2280,32 +2287,13 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-mac" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" -dependencies = [ - "generic-array", - "subtle", -] - -[[package]] -name = "ctr" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" -dependencies = [ - "cipher 0.3.0", -] - [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -2343,27 +2331,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", -] - -[[package]] -name = "darling" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" -dependencies = [ - "darling_core 0.13.4", - "darling_macro 0.13.4", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "syn 2.0.117", ] [[package]] @@ -2377,31 +2345,13 @@ dependencies = [ ] [[package]] -name = "darling_core" -version = "0.13.4" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 1.0.109", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.110", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -2415,30 +2365,21 @@ dependencies = [ "proc-macro2", "quote", "serde", - "strsim 0.11.1", - "syn 2.0.110", + "strsim", + "syn 2.0.117", ] [[package]] -name = "darling_macro" -version = "0.13.4" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "darling_core 0.13.4", + "ident_case", + "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.110", + "strsim", + "syn 2.0.117", ] [[package]] @@ -2449,7 +2390,18 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.110", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", ] [[package]] @@ -2488,15 +2440,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-encoding-macro" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -2504,12 +2456,12 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2558,7 +2510,7 @@ dependencies = [ "hex", "reqwest", "serde_json", - "sha2 0.9.9", + "sha2", "tree_hash", "types", ] @@ -2570,6 +2522,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", "zeroize", ] @@ -2587,6 +2542,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deranged" version = "0.5.5" @@ -2616,7 +2582,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2625,34 +2591,45 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", - "syn 2.0.110", + "rustc_version 0.4.1", + "syn 2.0.117", "unicode-xid", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.9.0" @@ -2668,7 +2645,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "const-oid", "crypto-common", "subtle", @@ -2709,11 +2686,11 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f170f4f6ed0e1df52bf43b403899f0081917ecf1500bfe312505cc3b515a8899" dependencies = [ - "aes 0.8.4", + "aes", "aes-gcm", "alloy-rlp", "arrayvec", - "ctr 0.9.2", + "ctr", "delay_map", "enr", "fnv", @@ -2756,7 +2733,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2837,7 +2814,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2 0.10.9", + "sha2", "subtle", "zeroize", ] @@ -2851,7 +2828,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2878,7 +2855,6 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "serde_yaml", "snap", "ssz_types", "state_processing", @@ -2887,6 +2863,7 @@ dependencies = [ "tree_hash_derive", "typenum", "types", + "yaml_serde", ] [[package]] @@ -2905,7 +2882,7 @@ dependencies = [ "itertools 0.14.0", "serde", "serde_json", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -2970,7 +2947,7 @@ dependencies = [ "ekzg-bls12-381", "ekzg-maybe-rayon", "ekzg-polynomial", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3074,7 +3051,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3094,7 +3071,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3132,7 +3109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3143,13 +3120,16 @@ dependencies = [ "context_deserialize", "educe", "eip_3076", + "enr", "eth2_keystore", "ethereum_serde_utils", "ethereum_ssz", "ethereum_ssz_derive", "futures", "futures-util", + "libp2p-identity", "mediatype", + "multiaddr", "pretty_reqwest_error", "proto_array", "rand 0.9.2", @@ -3184,7 +3164,7 @@ dependencies = [ "hex", "num-bigint", "serde", - "serde_yaml", + "yaml_serde", ] [[package]] @@ -3195,7 +3175,7 @@ dependencies = [ "hex", "num-bigint-dig", "ring", - "sha2 0.9.9", + "sha2", "zeroize", ] @@ -3203,21 +3183,23 @@ dependencies = [ name = "eth2_keystore" version = "0.1.0" dependencies = [ - "aes 0.7.5", + "aes", "bls", + "cipher", + "ctr", "eth2_key_derivation", "hex", - "hmac 0.11.0", - "pbkdf2 0.8.0", + "hmac", + "pbkdf2", "rand 0.9.2", "scrypt", "serde", "serde_json", "serde_repr", - "sha2 0.9.9", + "sha2", "tempfile", "unicode-normalization", - "uuid 0.8.2", + "uuid", "zeroize", ] @@ -3234,13 +3216,13 @@ dependencies = [ "pretty_reqwest_error", "reqwest", "sensitive_url", - "serde_yaml", - "sha2 0.9.9", + "sha2", "tempfile", "tokio", "tracing", "types", "url", + "yaml_serde", "zip", ] @@ -3257,7 +3239,7 @@ dependencies = [ "serde_repr", "tempfile", "tiny-bip39", - "uuid 0.8.2", + "uuid", ] [[package]] @@ -3277,7 +3259,7 @@ checksum = "5aa93f58bb1eb3d1e556e4f408ef1dac130bad01ac37db4e7ade45de40d1c86a" dependencies = [ "cpufeatures", "ring", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3295,15 +3277,15 @@ dependencies = [ [[package]] name = "ethereum_ssz" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8cd8c4f47dfb947dbfe3cdf2945ae1da808dbedc592668658e827a12659ba1" +checksum = "2128a84f7a3850d54ee343334e3392cca61f9f6aa9441eec481b9394b43c238b" dependencies = [ "alloy-primitives", "arbitrary", "context_deserialize", "ethereum_serde_utils", - "itertools 0.13.0", + "itertools 0.14.0", "serde", "serde_derive", "smallvec", @@ -3312,14 +3294,14 @@ dependencies = [ [[package]] name = "ethereum_ssz_derive" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78d247bc40823c365a62e572441a8f8b12df03f171713f06bc76180fcd56ab71" +checksum = "cd596f91cff004fc8d02be44c21c0f9b93140a04b66027ae052f5f8e05b48eba" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3423,7 +3405,7 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "sha2 0.9.9", + "sha2", "slot_clock", "ssz_types", "state_processing", @@ -3445,9 +3427,9 @@ dependencies = [ [[package]] name = "fallible-iterator" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] name = "fallible-streaming-iterator" @@ -3560,6 +3542,12 @@ dependencies = [ "safe_arith", ] +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.5" @@ -3567,6 +3555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "libz-sys", "miniz_oxide", ] @@ -3589,26 +3578,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "fork_choice" version = "0.1.0" dependencies = [ "beacon_chain", + "bls", "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", @@ -3671,12 +3646,12 @@ dependencies = [ [[package]] name = "futures-bounded" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +checksum = "b604752cefc5aa3ab98992a107a8bd99465d2825c1584e0b60cb6957b21e19d7" dependencies = [ - "futures-timer", "futures-util", + "tokio", ] [[package]] @@ -3731,7 +3706,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3762,6 +3737,10 @@ name = "futures-timer" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +dependencies = [ + "gloo-timers", + "send_wrapper", +] [[package]] name = "futures-util" @@ -3857,6 +3836,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "graffiti_file" version = "0.1.0" @@ -3894,7 +3885,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.12.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -3912,8 +3903,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.12.0", + "http 1.4.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -3959,7 +3950,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] [[package]] @@ -3975,21 +3965,13 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash 0.2.0", "serde", -] - -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", + "serde_core", ] [[package]] @@ -4003,11 +3985,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4077,9 +4059,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] @@ -4143,17 +4125,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hmac" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" -dependencies = [ - "crypto-mac", - "digest 0.9.0", + "hmac", ] [[package]] @@ -4165,15 +4137,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "0.2.12" @@ -4187,12 +4150,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -4214,7 +4176,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -4225,7 +4187,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -4252,7 +4214,6 @@ dependencies = [ "health_metrics", "hex", "lighthouse_network", - "lighthouse_tracing", "lighthouse_version", "logging", "lru 0.12.5", @@ -4263,6 +4224,7 @@ dependencies = [ "parking_lot", "proto_array", "rand 0.9.2", + "reqwest", "safe_arith", "sensitive_url", "serde", @@ -4358,7 +4320,7 @@ dependencies = [ "futures-channel", "futures-core", "h2 0.4.12", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", @@ -4376,7 +4338,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", + "http 1.4.0", "hyper 1.8.1", "hyper-util", "rustls 0.23.35", @@ -4400,41 +4362,25 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.8.1", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -4452,7 +4398,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -4512,9 +4458,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -4526,9 +4472,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -4574,25 +4520,35 @@ dependencies = [ [[package]] name = "if-addrs" -version = "0.10.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabb0019d51a643781ff15c9c8a3e5dedc365c47211270f4e8f82812fedd8f0a" +checksum = "bf39cc0423ee66021dc5eccface85580e4a001e0c5288bae8bea7ecb69225e90" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] name = "if-watch" -version = "3.2.1" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf9d64cfcf380606e64f9a0bcf493616b65331199f984151a6fa11a7b3cde38" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" dependencies = [ "async-io", "core-foundation 0.9.4", "fnv", "futures", - "if-addrs", + "if-addrs 0.15.0", "ipnet", "log", "netlink-packet-core", @@ -4615,7 +4571,7 @@ dependencies = [ "attohttpc", "bytes", "futures", - "http 1.3.1", + "http 1.4.0", "http-body-util", "hyper 1.8.1", "hyper-util", @@ -4643,7 +4599,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4659,13 +4615,13 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "arbitrary", "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -4681,7 +4637,9 @@ dependencies = [ "filesystem", "lockfile", "metrics", + "p12-keystore", "parking_lot", + "pem", "rand 0.9.2", "reqwest", "serde", @@ -4702,6 +4660,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -4751,17 +4710,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -4822,9 +4770,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -4856,15 +4804,15 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2 0.10.9", + "sha2", "signature", ] [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] @@ -4894,7 +4842,6 @@ name = "kzg" version = "0.1.0" dependencies = [ "arbitrary", - "c-kzg", "criterion", "educe", "ethereum_hashing", @@ -4927,7 +4874,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.0.1" +version = "8.1.3" dependencies = [ "account_utils", "beacon_chain", @@ -4952,7 +4899,6 @@ dependencies = [ "rayon", "serde", "serde_json", - "serde_yaml", "snap", "state_processing", "store", @@ -4961,6 +4907,7 @@ dependencies = [ "tree_hash", "types", "validator_dir", + "yaml_serde", ] [[package]] @@ -4988,9 +4935,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libloading" @@ -5025,9 +4972,8 @@ dependencies = [ [[package]] name = "libp2p" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a" +version = "0.57.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "bytes", "either", @@ -5038,12 +4984,12 @@ dependencies = [ "libp2p-connection-limits", "libp2p-core", "libp2p-dns", + "libp2p-gossipsub", "libp2p-identify", "libp2p-identity", "libp2p-mdns", "libp2p-metrics", "libp2p-noise", - "libp2p-plaintext", "libp2p-quic", "libp2p-swarm", "libp2p-tcp", @@ -5057,9 +5003,8 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5068,9 +5013,8 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5079,9 +5023,8 @@ dependencies = [ [[package]] name = "libp2p-core" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d28e2d2def7c344170f5c6450c0dbe3dfef655610dbfde2f6ac28a527abbe36" +version = "0.44.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "either", "fnv", @@ -5098,17 +5041,15 @@ dependencies = [ "rw-stream-sink", "thiserror 2.0.17", "tracing", - "unsigned-varint 0.8.0", + "unsigned-varint", "web-time", ] [[package]] name = "libp2p-dns" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" +version = "0.45.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ - "async-trait", "futures", "hickory-resolver", "libp2p-core", @@ -5121,7 +5062,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=5acdf89a65d64098f9346efa5769e57bcd19dea9#5acdf89a65d64098f9346efa5769e57bcd19dea9" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5133,7 +5074,7 @@ dependencies = [ "futures", "futures-timer", "getrandom 0.2.16", - "hashlink 0.10.0", + "hashlink 0.11.0", "hex_fmt", "libp2p-core", "libp2p-identity", @@ -5143,16 +5084,15 @@ dependencies = [ "quick-protobuf-codec", "rand 0.8.5", "regex", - "sha2 0.10.9", + "sha2", "tracing", "web-time", ] [[package]] name = "libp2p-identify" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ab792a8b68fdef443a62155b01970c81c3aadab5e659621b063ef252a8e65e8" +version = "0.48.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "either", @@ -5171,9 +5111,9 @@ dependencies = [ [[package]] name = "libp2p-identity" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3104e13b51e4711ff5738caa1fb54467c8604c2e94d607e27745bcf709068774" +checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" dependencies = [ "asn1_der", "bs58 0.5.1", @@ -5183,7 +5123,7 @@ dependencies = [ "multihash", "quick-protobuf", "rand 0.8.5", - "sha2 0.10.9", + "sha2", "thiserror 2.0.17", "tracing", "zeroize", @@ -5191,9 +5131,8 @@ dependencies = [ [[package]] name = "libp2p-mdns" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443" +version = "0.49.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "hickory-proto", @@ -5203,19 +5142,19 @@ dependencies = [ "libp2p-swarm", "rand 0.8.5", "smallvec", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tracing", ] [[package]] name = "libp2p-metrics" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" +version = "0.18.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "libp2p-core", + "libp2p-gossipsub", "libp2p-identify", "libp2p-identity", "libp2p-swarm", @@ -5226,9 +5165,8 @@ dependencies = [ [[package]] name = "libp2p-mplex" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a4019ba30c4e42b776113e9778071691fe3f34bf23b6b3bf0dfcf29d801f3d" +version = "0.44.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "bytes", @@ -5240,14 +5178,13 @@ dependencies = [ "rand 0.8.5", "smallvec", "tracing", - "unsigned-varint 0.8.0", + "unsigned-varint", ] [[package]] name = "libp2p-noise" -version = "0.46.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc73eacbe6462a0eb92a6527cac6e63f02026e5407f8831bde8293f19217bfbf" +version = "0.47.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "bytes", @@ -5266,27 +5203,10 @@ dependencies = [ "zeroize", ] -[[package]] -name = "libp2p-plaintext" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e659439578fc6d305da8303834beb9d62f155f40e7f5b9d81c9f2b2c69d1926" -dependencies = [ - "asynchronous-codec", - "bytes", - "futures", - "libp2p-core", - "libp2p-identity", - "quick-protobuf", - "quick-protobuf-codec", - "tracing", -] - [[package]] name = "libp2p-quic" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f" +version = "0.14.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-timer", @@ -5298,7 +5218,7 @@ dependencies = [ "rand 0.8.5", "ring", "rustls 0.23.35", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.17", "tokio", "tracing", @@ -5306,58 +5226,56 @@ dependencies = [ [[package]] name = "libp2p-swarm" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aa762e5215919a34e31c35d4b18bf2e18566ecab7f8a3d39535f4a3068f8b62" +version = "0.48.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "either", "fnv", "futures", "futures-timer", + "getrandom 0.2.16", + "hashlink 0.11.0", "libp2p-core", "libp2p-identity", "libp2p-swarm-derive", - "lru 0.12.5", "multistream-select", "rand 0.8.5", "smallvec", "tokio", "tracing", + "wasm-bindgen-futures", "web-time", ] [[package]] name = "libp2p-swarm-derive" -version = "0.35.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" +version = "0.36.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "heck", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "libp2p-tcp" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65b4e030c52c46c8d01559b2b8ca9b7c4185f10576016853129ca1fe5cd1a644" +version = "0.45.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-timer", "if-watch", "libc", "libp2p-core", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tracing", ] [[package]] name = "libp2p-tls" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-rustls", @@ -5366,7 +5284,7 @@ dependencies = [ "rcgen", "ring", "rustls 0.23.35", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.13", "thiserror 2.0.17", "x509-parser", "yasna", @@ -5374,9 +5292,8 @@ dependencies = [ [[package]] name = "libp2p-upnp" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-timer", @@ -5389,9 +5306,8 @@ dependencies = [ [[package]] name = "libp2p-yamux" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f15df094914eb4af272acf9adaa9e287baa269943f32ea348ba29cfb9bfc60d8" +version = "0.48.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "either", "futures", @@ -5399,14 +5315,14 @@ dependencies = [ "thiserror 2.0.17", "tracing", "yamux 0.12.1", - "yamux 0.13.8", + "yamux 0.13.10", ] [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" dependencies = [ "bitflags 2.10.0", "libc", @@ -5414,15 +5330,30 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.25.2" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + +[[package]] +name = "libz-rs-sys" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15413ef615ad868d4d65dce091cb233b229419c7c0c4bcaa746c0901c49ff39c" +dependencies = [ + "zlib-rs", +] + [[package]] name = "libz-sys" version = "1.1.23" @@ -5436,7 +5367,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.0.1" +version = "8.1.3" dependencies = [ "account_manager", "account_utils", @@ -5457,7 +5388,6 @@ dependencies = [ "futures", "initialized_validators", "lighthouse_network", - "lighthouse_tracing", "lighthouse_version", "logging", "malloc_utils", @@ -5469,7 +5399,6 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "serde_yaml", "slasher", "slashing_protection", "store", @@ -5478,10 +5407,12 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "tracing_samplers", "types", "validator_client", "validator_dir", "validator_manager", + "yaml_serde", "zeroize", ] @@ -5506,12 +5437,12 @@ dependencies = [ "fnv", "futures", "hex", - "itertools 0.10.5", + "if-addrs 0.14.0", + "itertools 0.14.0", "libp2p", "libp2p-gossipsub", "libp2p-mplex", "lighthouse_version", - "local-ip-address", "logging", "lru 0.12.5", "lru_cache", @@ -5523,7 +5454,7 @@ dependencies = [ "rand 0.9.2", "regex", "serde", - "sha2 0.9.9", + "sha2", "smallvec", "snap", "ssz_types", @@ -5537,13 +5468,9 @@ dependencies = [ "tracing-subscriber", "typenum", "types", - "unsigned-varint 0.8.0", + "unsigned-varint", ] -[[package]] -name = "lighthouse_tracing" -version = "0.1.0" - [[package]] name = "lighthouse_validator_store" version = "0.1.0" @@ -5573,17 +5500,11 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.0.1" +version = "8.1.3" dependencies = [ "regex", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -5617,18 +5538,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "local-ip-address" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b3b27f8893f7bbf9485148ff9a65f019e3f33bd5cdc87c83cab16b3fd9ec8" -dependencies = [ - "libc", - "neli", - "thiserror 2.0.17", - "windows-sys 0.59.0", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -5648,9 +5557,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logging" @@ -5731,7 +5640,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5753,13 +5662,13 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "match-lookup" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5788,7 +5697,7 @@ name = "mdbx-sys" version = "0.11.6-4" source = "git+https://github.com/sigp/libmdbx-rs?rev=e6ff4b9377c1619bcf0bfdf52bee5a980a432a1a#e6ff4b9377c1619bcf0bfdf52bee5a980a432a1a" dependencies = [ - "bindgen", + "bindgen 0.69.5", "cc", "cmake", "libc", @@ -5828,25 +5737,25 @@ dependencies = [ [[package]] name = "metastruct" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74f54f231f9a18d77393ecc5cc7ab96709b2a61ee326c2b2b291009b0cc5a07" +checksum = "969a1be9bd80794bdf93b23ab552c2ec6f3e83b33164824553fd996cdad513b8" dependencies = [ "metastruct_macro", ] [[package]] name = "metastruct_macro" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7225f3a4dfbec47a0c6a730a874185fda840d365d7bbd6ba199dd81796d5" +checksum = "de9164f767d73a507c19205868c84da411dc7795f4bdabf497d3dd93cfef9930" dependencies = [ - "darling 0.13.4", - "itertools 0.10.5", + "darling 0.23.0", + "itertools 0.14.0", "proc-macro2", "quote", "smallvec", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5914,9 +5823,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -5952,7 +5861,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5964,25 +5873,26 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "mockito" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" +checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de" dependencies = [ "assert-json-diff", "bytes", "colored", - "futures-util", - "http 1.3.1", + "futures-core", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-util", "log", + "pin-project-lite", "rand 0.9.2", "regex", "serde_json", @@ -6006,7 +5916,7 @@ dependencies = [ "rustc_version 0.4.1", "smallvec", "tagptr", - "uuid 1.18.1", + "uuid", ] [[package]] @@ -6049,7 +5959,7 @@ dependencies = [ "percent-encoding", "serde", "static_assertions", - "unsigned-varint 0.8.0", + "unsigned-varint", "url", ] @@ -6072,107 +5982,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" dependencies = [ "core2", - "unsigned-varint 0.8.0", + "unsigned-varint", ] [[package]] name = "multistream-select" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" +version = "0.14.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "bytes", "futures", - "log", "pin-project", "smallvec", - "unsigned-varint 0.7.2", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "neli" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" -dependencies = [ - "byteorder", - "libc", - "log", - "neli-proc-macros", -] - -[[package]] -name = "neli-proc-macros" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8034b7fbb6f9455b2a96c19e6edf8dc9fc34c70449938d8ee3b4df363f61fe" -dependencies = [ - "either", - "proc-macro2", - "quote", - "serde", - "syn 1.0.109", + "tracing", + "unsigned-varint", ] [[package]] name = "netlink-packet-core" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" dependencies = [ - "anyhow", - "byteorder", - "netlink-packet-utils", + "paste", ] [[package]] name = "netlink-packet-route" -version = "0.17.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053998cea5a306971f88580d0829e90f270f940befd7cf928da179d4187a5a66" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" dependencies = [ - "anyhow", - "bitflags 1.3.2", - "byteorder", + "bitflags 2.10.0", "libc", + "log", "netlink-packet-core", - "netlink-packet-utils", -] - -[[package]] -name = "netlink-packet-utils" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" -dependencies = [ - "anyhow", - "byteorder", - "paste", - "thiserror 1.0.69", ] [[package]] name = "netlink-proto" -version = "0.11.5" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72452e012c2f8d612410d89eea01e2d9b56205274abb35d53f60200b2ec41d60" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" dependencies = [ "bytes", "futures", @@ -6184,12 +6035,12 @@ dependencies = [ [[package]] name = "netlink-sys" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ "bytes", - "futures", + "futures-util", "libc", "log", "tokio", @@ -6218,18 +6069,18 @@ dependencies = [ "genesis", "hex", "igd-next", - "itertools 0.10.5", + "itertools 0.14.0", "k256", "kzg", - "libp2p-gossipsub", + "libp2p", "lighthouse_network", - "lighthouse_tracing", "logging", "lru_cache", "matches", "metrics", "operation_pool", "parking_lot", + "paste", "rand 0.8.5", "rand 0.9.2", "rand_chacha 0.3.1", @@ -6275,17 +6126,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - [[package]] name = "nix" version = "0.30.1" @@ -6307,6 +6147,7 @@ dependencies = [ "environment", "eth2", "execution_layer", + "reqwest", "sensitive_url", "tempfile", "tokio", @@ -6346,7 +6187,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6378,9 +6219,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -6440,7 +6281,7 @@ checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6516,60 +6357,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.110", -] - [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-src" -version = "300.5.4+3.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.30.0" @@ -6592,7 +6385,7 @@ checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" dependencies = [ "async-trait", "bytes", - "http 1.3.1", + "http 1.4.0", "opentelemetry", "reqwest", ] @@ -6603,7 +6396,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" dependencies = [ - "http 1.3.1", + "http 1.4.0", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", @@ -6655,7 +6448,7 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", - "itertools 0.10.5", + "itertools 0.14.0", "maplit", "metrics", "parking_lot", @@ -6670,6 +6463,39 @@ dependencies = [ "types", ] +[[package]] +name = "p12-keystore" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d55319bae67f92141ce4da80c5392acd3d1323bd8312c1ffdfb018927d07d7" +dependencies = [ + "base64 0.22.1", + "cbc", + "cms", + "der", + "des", + "hex", + "hmac", + "pkcs12", + "pkcs5", + "rand 0.9.2", + "rc2", + "sha1", + "sha2", + "thiserror 2.0.17", + "x509-parser", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pairing" version = "0.23.0" @@ -6704,7 +6530,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6736,17 +6562,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" @@ -6755,23 +6570,12 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" -version = "0.8.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95f5254224e617595d2cc3cc73ff0a5eaf2637519e25f03388154e9378b6ffa" -dependencies = [ - "crypto-mac", -] - -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", - "password-hash", - "sha2 0.10.9", + "hmac", ] [[package]] @@ -6784,6 +6588,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6792,9 +6605,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" dependencies = [ "memchr", "ucd-trie", @@ -6802,22 +6615,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6832,6 +6645,36 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs12" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" +dependencies = [ + "cms", + "const-oid", + "der", + "digest 0.10.7", + "spki", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -6892,7 +6735,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix", "windows-sys 0.61.2", ] @@ -6990,7 +6833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -7032,7 +6875,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -7052,7 +6895,7 @@ checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ "bitflags 2.10.0", "procfs-core", - "rustix 1.1.2", + "rustix", ] [[package]] @@ -7081,9 +6924,9 @@ dependencies = [ [[package]] name = "prometheus-client" -version = "0.23.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c" +checksum = "e4500adecd7af8e0e9f4dbce15cfee07ce913fbf6ad605cc468b83f2d531ee94" dependencies = [ "dtoa", "itoa", @@ -7093,13 +6936,13 @@ dependencies = [ [[package]] name = "prometheus-client-derive-encode" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +checksum = "9adf1691c04c0a5ff46ff8f262b58beb07b0dbb61f96f9f54f6cbd82106ed87f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -7129,7 +6972,7 @@ checksum = "095a99f75c69734802359b682be8daaf8980296731f6470434ea2c652af1dd30" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -7149,10 +6992,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -7173,10 +7016,12 @@ dependencies = [ "fixed_bytes", "safe_arith", "serde", - "serde_yaml", + "smallvec", "superstruct", "tracing", + "typenum", "types", + "yaml_serde", ] [[package]] @@ -7207,22 +7052,21 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-protobuf" version = "0.8.1" -source = "git+https://github.com/sigp/quick-protobuf.git?rev=681f413312404ab6e51f0b46f39b0075c6f4ebfd#681f413312404ab6e51f0b46f39b0075c6f4ebfd" +source = "git+https://github.com/sigp/quick-protobuf.git?rev=87c4ccb9bb2af494de375f5f6c62850badd26304#87c4ccb9bb2af494de375f5f6c62850badd26304" dependencies = [ "byteorder", ] [[package]] name = "quick-protobuf-codec" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" +version = "0.4.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "bytes", "quick-protobuf", - "thiserror 1.0.69", - "unsigned-varint 0.8.0", + "thiserror 2.0.17", + "unsigned-varint", ] [[package]] @@ -7239,7 +7083,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.35", - "socket2 0.6.1", + "socket2 0.6.3", "thiserror 2.0.17", "tokio", "tracing", @@ -7248,9 +7092,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -7276,7 +7120,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -7309,12 +7153,13 @@ dependencies = [ [[package]] name = "r2d2_sqlite" -version = "0.21.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4f5d0337e99cd5cacd91ffc326c6cc9d8078def459df560c4f9bf9ba4a51034" +checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" dependencies = [ "r2d2", "rusqlite", + "uuid", ] [[package]] @@ -7403,6 +7248,15 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rapidhash" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e65c75143ce5d47c55b510297eeb1182f3c739b6043c537670e9fc18612dae" +dependencies = [ + "rustversion", +] + [[package]] name = "rayon" version = "1.11.0" @@ -7423,6 +7277,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -7482,14 +7345,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -7516,25 +7379,23 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -7545,7 +7406,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.2", @@ -7587,7 +7447,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -7635,19 +7495,29 @@ dependencies = [ ] [[package]] -name = "rtnetlink" -version = "0.13.1" +name = "rsqlite-vfs" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a552eb82d19f38c3beed3f786bd23aa434ceb9ac43ab44419ca6d67a7e186c0" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ - "futures", + "hashbrown 0.16.1", + "thiserror 2.0.17", +] + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", "log", "netlink-packet-core", "netlink-packet-route", - "netlink-packet-utils", "netlink-proto", "netlink-sys", - "nix 0.26.4", + "nix 0.30.1", "thiserror 1.0.69", "tokio", ] @@ -7689,16 +7559,17 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rusqlite" -version = "0.28.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", - "hashlink 0.8.4", + "hashlink 0.11.0", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -7763,19 +7634,6 @@ dependencies = [ "nom", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.2" @@ -7785,8 +7643,8 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "linux-raw-sys", + "windows-sys 0.60.2", ] [[package]] @@ -7813,7 +7671,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -7827,7 +7685,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -7841,9 +7699,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -7862,9 +7720,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -7891,9 +7749,8 @@ dependencies = [ [[package]] name = "rw-stream-sink" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +version = "0.5.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "pin-project", @@ -7914,11 +7771,11 @@ checksum = "b147bb6111014916d3ef9d4c85173124a8e12193a67f6176d67244afd558d6c1" [[package]] name = "salsa20" -version = "0.8.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecbd2eb639fd7cab5804a0837fe373cc2172d15437e804c054a9fb885cb923b0" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher 0.3.0", + "cipher", ] [[package]] @@ -7986,14 +7843,13 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scrypt" -version = "0.7.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879588d8f90906e73302547e20fffefdd240eb3e0e744e142321f5d49dea0518" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "hmac 0.11.0", - "pbkdf2 0.8.0", + "pbkdf2", "salsa20", - "sha2 0.9.9", + "sha2", ] [[package]] @@ -8032,19 +7888,6 @@ dependencies = [ "cc", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" @@ -8096,6 +7939,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" + [[package]] name = "sensitive_url" version = "0.1.0" @@ -8143,7 +7992,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8167,7 +8016,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8184,15 +8033,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10574371d41b0d9b2cff89418eda27da52bcaff2cc8741db26382a77c29131f1" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.12.1", "schemars 0.9.0", "schemars 1.1.0", "serde_core", @@ -8203,27 +8052,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.110", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.12.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "syn 2.0.117", ] [[package]] @@ -8247,19 +8083,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha2" version = "0.10.9" @@ -8308,9 +8131,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] @@ -8345,9 +8168,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "similar" @@ -8506,7 +8329,7 @@ dependencies = [ "rand_core 0.6.4", "ring", "rustc_version 0.4.1", - "sha2 0.10.9", + "sha2", "subtle", ] @@ -8522,9 +8345,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.60.2", @@ -8547,10 +8370,22 @@ dependencies = [ ] [[package]] -name = "ssz_types" -version = "0.14.0" +name = "sqlite-wasm-rs" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc20a89bab2dabeee65e9c9eb96892dc222c23254b401e1319b85efd852fa31" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "ssz_types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d625e4de8e0057eefe7e0b1510ba1dd7adf10cd375fad6cc7fcceac7c39623c9" dependencies = [ "arbitrary", "context_deserialize", @@ -8585,7 +8420,7 @@ dependencies = [ "fixed_bytes", "int_to_bytes", "integer-sqrt", - "itertools 0.10.5", + "itertools 0.14.0", "merkle_proof", "metrics", "milhouse", @@ -8633,7 +8468,7 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", - "itertools 0.10.5", + "itertools 0.14.0", "leveldb", "logging", "lru 0.12.5", @@ -8655,15 +8490,9 @@ dependencies = [ "typenum", "types", "xdelta3", - "zstd 0.13.3", + "zstd", ] -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" @@ -8688,7 +8517,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8699,16 +8528,16 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "superstruct" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b986e4a629907f20a2c2a639a75bc22a8b5d99b444e0d83c395f4cb309022bf" +checksum = "bae4a9ccd7882533c1f210e400763ec6ee64c390fc12248c238276281863719e" dependencies = [ - "darling 0.20.11", - "itertools 0.13.0", + "darling 0.23.0", + "itertools 0.14.0", "proc-macro2", "quote", "smallvec", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8734,9 +8563,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -8745,14 +8574,14 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff790eb176cc81bb8936aed0f7b9f14fc4670069a2d371b3e3b0ecce908b2cb3" +checksum = "60ceeb7c95a4536de0c0e1649bd98d1a72a4bb9590b1f3e45a8a0bfdb7c188c0" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8772,7 +8601,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8792,9 +8621,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -8865,8 +8694,8 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", - "windows-sys 0.52.0", + "rustix", + "windows-sys 0.60.2", ] [[package]] @@ -8875,7 +8704,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix", "windows-sys 0.60.2", ] @@ -8890,7 +8719,7 @@ name = "test_random_derive" version = "0.2.0" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8919,7 +8748,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8930,7 +8759,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -8984,30 +8813,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -9026,17 +8855,15 @@ dependencies = [ [[package]] name = "tiny-bip39" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62cc94d358b5a1e84a5cb9109f559aa3c4d634d2b1b4de3d0fa4adc7c78e2861" +checksum = "a30fd743a02bf35236f6faf99adb03089bb77e91c998dac2c2ad76bb424f668c" dependencies = [ - "anyhow", - "hmac 0.12.1", "once_cell", - "pbkdf2 0.11.0", + "pbkdf2", "rand 0.8.5", "rustc-hash 1.1.0", - "sha2 0.10.9", + "sha2", "thiserror 1.0.69", "unicode-normalization", "wasm-bindgen", @@ -9089,9 +8916,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -9099,7 +8926,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -9113,17 +8940,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", + "syn 2.0.117", ] [[package]] @@ -9176,20 +8993,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.4+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "fe3cea6b2aa3b910092f6abd4053ea464fab5f9c170ba5e9a6aead16ec4af2b6" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "toml_datetime", "toml_parser", "winnow", @@ -9197,9 +9014,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.5+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "4c03bee5ce3696f31250db0bbaff18bc43301ce0e8db2ed1f07cbb2acf89984c" dependencies = [ "winnow", ] @@ -9216,7 +9033,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2 0.4.12", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", @@ -9243,7 +9060,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", @@ -9290,7 +9107,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.12.0", + "indexmap 2.12.1", "pin-project-lite", "slab", "sync_wrapper", @@ -9303,14 +9120,14 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "iri-string", "pin-project-lite", @@ -9333,9 +9150,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -9345,32 +9162,32 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 1.0.69", + "thiserror 2.0.17", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -9417,9 +9234,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -9436,11 +9253,19 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing_samplers" +version = "0.1.0" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", +] + [[package]] name = "tree_hash" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2db21caa355767db4fd6129876e5ae278a8699f4a6959b1e3e7aff610b532d52" +checksum = "f7fd51aa83d2eb83b04570808430808b5d24fdbf479a4d5ac5dee4a2e2dd2be4" dependencies = [ "alloy-primitives", "ethereum_hashing", @@ -9451,14 +9276,14 @@ dependencies = [ [[package]] name = "tree_hash_derive" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711cc655fcbb48384a87dc2bf641b991a15c5ad9afc3caa0b1ab1df3b436f70f" +checksum = "8840ad4d852e325d3afa7fde8a50b2412f89dce47d7eb291c0cc7f87cd040f38" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -9514,7 +9339,7 @@ dependencies = [ "fixed_bytes", "hex", "int_to_bytes", - "itertools 0.10.5", + "itertools 0.14.0", "kzg", "maplit", "merkle_proof", @@ -9531,7 +9356,6 @@ dependencies = [ "safe_arith", "serde", "serde_json", - "serde_yaml", "smallvec", "ssz_types", "state_processing", @@ -9544,6 +9368,7 @@ dependencies = [ "tree_hash", "tree_hash_derive", "typenum", + "yaml_serde", ] [[package]] @@ -9609,6 +9434,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -9625,18 +9456,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "unsigned-varint" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" - [[package]] name = "unsigned-varint" version = "0.8.0" @@ -9680,28 +9499,20 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "0.8.2" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.16", - "serde", -] - -[[package]] -name = "uuid" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", + "rand 0.9.2", + "serde_core", "wasm-bindgen", ] [[package]] name = "validator_client" -version = "8.0.1" +version = "8.1.3" dependencies = [ "account_utils", "beacon_node_fallback", @@ -9772,7 +9583,7 @@ dependencies = [ "graffiti_file", "health_metrics", "initialized_validators", - "itertools 0.10.5", + "itertools 0.14.0", "lighthouse_validator_store", "lighthouse_version", "logging", @@ -9872,6 +9683,7 @@ dependencies = [ "graffiti_file", "logging", "parking_lot", + "reqwest", "safe_arith", "slot_clock", "task_executor", @@ -9889,6 +9701,7 @@ version = "0.1.0" dependencies = [ "bls", "eth2", + "futures", "slashing_protection", "types", ] @@ -9900,6 +9713,7 @@ dependencies = [ "eth2", "mockito", "regex", + "reqwest", "sensitive_url", "serde_json", "tracing", @@ -9994,6 +9808,7 @@ dependencies = [ "bytes", "eth2", "headers", + "reqwest", "safe_arith", "serde", "serde_array_query", @@ -10020,9 +9835,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -10033,9 +9848,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -10046,9 +9861,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10056,22 +9871,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -10105,9 +9920,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -10143,7 +9958,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_yaml", "slashing_protection", "slot_clock", "ssz_types", @@ -10153,6 +9967,7 @@ dependencies = [ "types", "url", "validator_store", + "yaml_serde", "zip", ] @@ -10165,18 +9980,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "widestring" version = "0.4.3" @@ -10211,7 +10014,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] @@ -10222,12 +10025,14 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.53.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efc5cf48f83140dcaab716eeaea345f9e93d0018fb81162753a3f76c3397b538" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.53.0", - "windows-targets 0.52.6", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -10243,13 +10048,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.53.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcc5b895a6377f1ab9fa55acedab1fd5ac0db66ad1e6c7f47e28a22e446a5dd" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -10261,10 +10065,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -10273,7 +10088,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -10284,7 +10099,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -10294,12 +10109,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core", + "windows-link", ] [[package]] @@ -10413,6 +10229,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -10553,9 +10378,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -10611,6 +10436,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", +] + [[package]] name = "x509-parser" version = "0.17.0" @@ -10631,15 +10467,15 @@ dependencies = [ [[package]] name = "xdelta3" version = "0.1.5" -source = "git+https://github.com/sigp/xdelta3-rs?rev=4db64086bb02e9febb584ba93b9d16bb2ae3825a#4db64086bb02e9febb584ba93b9d16bb2ae3825a" +source = "git+https://github.com/sigp/xdelta3-rs?rev=fe3906605c87b6c0515bd7c8fc671f47875e3ccc#fe3906605c87b6c0515bd7c8fc671f47875e3ccc" dependencies = [ - "bindgen", + "bindgen 0.72.1", "cc", "futures-io", "futures-util", "libc", "log", - "rand 0.8.5", + "rand 0.9.2", ] [[package]] @@ -10659,13 +10495,26 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.8.4", + "hashlink 0.11.0", +] + +[[package]] +name = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "libyaml-rs", + "ryu", + "serde", ] [[package]] @@ -10685,9 +10534,9 @@ dependencies = [ [[package]] name = "yamux" -version = "0.13.8" +version = "0.13.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deab71f2e20691b4728b349c6cee8fc7223880fa67b6b4f92225ec32225447e5" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" dependencies = [ "futures", "log", @@ -10727,28 +10576,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -10768,7 +10617,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -10790,7 +10639,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -10823,36 +10672,39 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "zip" -version = "0.6.6" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" dependencies = [ - "aes 0.8.4", - "byteorder", - "bzip2", - "constant_time_eq", + "arbitrary", "crc32fast", - "crossbeam-utils", "flate2", - "hmac 0.12.1", - "pbkdf2 0.11.0", - "sha1", - "time", - "zstd 0.11.2+zstd.1.5.2", + "indexmap 2.12.1", + "memchr", + "zopfli", ] [[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" +name = "zlib-rs" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +checksum = "51f936044d677be1a1168fae1d03b583a285a5dd9d8cbf7b24c23aa1fc775235" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", + "bumpalo", + "crc32fast", + "log", + "simd-adler32", ] [[package]] @@ -10861,17 +10713,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe 7.2.4", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", + "zstd-safe", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d5d1687c76..1f58c322f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "beacon_node/http_api", "beacon_node/http_metrics", "beacon_node/lighthouse_network", - "beacon_node/lighthouse_tracing", "beacon_node/network", "beacon_node/operation_pool", "beacon_node/store", @@ -44,6 +43,7 @@ members = [ "common/target_check", "common/task_executor", "common/test_random_derive", + "common/tracing_samplers", "common/validator_dir", "common/warp_utils", "common/workspace_members", @@ -91,7 +91,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.0.1" +version = "8.1.3" [workspace.dependencies] account_utils = { path = "common/account_utils" } @@ -116,17 +116,14 @@ bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } byteorder = "1" -bytes = "1" -# Turn off c-kzg's default features which include `blst/portable`. We can turn on blst's portable -# feature ourselves when desired. -c-kzg = { version = "2.1", default-features = false } +bytes = "1.11.1" cargo_metadata = "0.19" clap = { version = "4.5.4", features = ["derive", "cargo", "wrap_help"] } clap_utils = { path = "common/clap_utils" } compare_fields = "0.1" console-subscriber = "0.4" context_deserialize = "0.2" -criterion = "0.5" +criterion = "0.8" delay_map = "0.4" deposit_contract = { path = "common/deposit_contract" } directory = { path = "common/directory" } @@ -148,7 +145,6 @@ ethereum_serde_utils = "0.8.0" ethereum_ssz = { version = "0.10.0", features = ["context_deserialize"] } ethereum_ssz_derive = "0.10.0" execution_layer = { path = "beacon_node/execution_layer" } -exit-future = "0.2" filesystem = { path = "common/filesystem" } fixed_bytes = { path = "consensus/fixed_bytes" } fnv = "1" @@ -156,8 +152,6 @@ fork_choice = { path = "consensus/fork_choice" } fs2 = "0.4" futures = "0.3" genesis = { path = "beacon_node/genesis" } -# This is tracking the sigp-gossipsub branch on sigp/rust-libp2p commit: Aug 20 2025 -gossipsub = { package = "libp2p-gossipsub", git = "https://github.com/sigp/rust-libp2p.git", rev = "5acdf89a65d64098f9346efa5769e57bcd19dea9", "features" = ["metrics"] } graffiti_file = { path = "validator_client/graffiti_file" } hashlink = "0.9.0" health_metrics = { path = "common/health_metrics" } @@ -166,11 +160,11 @@ http_api = { path = "beacon_node/http_api" } hyper = "1" initialized_validators = { path = "validator_client/initialized_validators" } int_to_bytes = { path = "consensus/int_to_bytes" } -itertools = "0.10" +itertools = "0.14" kzg = { path = "crypto/kzg" } +libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = ["identify", "yamux", "noise", "dns", "tcp", "tokio", "secp256k1", "macros", "metrics", "quic", "upnp", "gossipsub"] } libsecp256k1 = "0.7" lighthouse_network = { path = "beacon_node/lighthouse_network" } -lighthouse_tracing = { path = "beacon_node/lighthouse_tracing" } lighthouse_validator_store = { path = "validator_client/lighthouse_validator_store" } lighthouse_version = { path = "common/lighthouse_version" } lockfile = { path = "common/lockfile" } @@ -208,29 +202,22 @@ r2d2 = "0.8" rand = "0.9.0" rayon = "1.7" regex = "1" -reqwest = { version = "0.12", default-features = false, features = [ - "blocking", - "json", - "stream", - "rustls-tls", - "native-tls-vendored", -] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls"] } ring = "0.17" rpds = "0.11" -rusqlite = { version = "0.28", features = ["bundled"] } +rusqlite = { version = "0.38", features = ["bundled"] } rust_eth_kzg = "0.9" safe_arith = "0.1" sensitive_url = { version = "0.1", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" -serde_yaml = "0.9" -sha2 = "0.9" +sha2 = "0.10" signing_method = { path = "validator_client/signing_method" } slasher = { path = "slasher", default-features = false } slashing_protection = { path = "validator_client/slashing_protection" } slot_clock = { path = "common/slot_clock" } -smallvec = { version = "1.11.2", features = ["arbitrary"] } +smallvec = "1" snap = "1" ssz_types = { version = "0.14.0", features = ["context_deserialize", "runtime_types"] } state_processing = { path = "consensus/state_processing" } @@ -243,12 +230,7 @@ sysinfo = "0.26" system_health = { path = "common/system_health" } task_executor = { path = "common/task_executor" } tempfile = "3" -tokio = { version = "1", features = [ - "rt-multi-thread", - "sync", - "signal", - "macros", -] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "signal", "macros"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec", "compat", "time"] } tracing = "0.1.40" @@ -257,12 +239,13 @@ tracing-core = "0.1" tracing-log = "0.2" tracing-opentelemetry = "0.31.0" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing_samplers = { path = "common/tracing_samplers" } tree_hash = "0.12.0" tree_hash_derive = "0.12.0" typenum = "1" -types = { path = "consensus/types" } +types = { path = "consensus/types", features = ["saturating-arith"] } url = "2" -uuid = { version = "0.8", features = ["serde", "v4"] } +uuid = { version = "1", features = ["serde", "v4"] } validator_client = { path = "validator_client" } validator_dir = { path = "common/validator_dir" } validator_http_api = { path = "validator_client/http_api" } @@ -274,9 +257,10 @@ validator_test_rig = { path = "testing/validator_test_rig" } warp = { version = "0.3.7", default-features = false, features = ["tls"] } warp_utils = { path = "common/warp_utils" } workspace_members = { path = "common/workspace_members" } -xdelta3 = { git = "https://github.com/sigp/xdelta3-rs", rev = "4db64086bb02e9febb584ba93b9d16bb2ae3825a" } +xdelta3 = { git = "https://github.com/sigp/xdelta3-rs", rev = "fe3906605c87b6c0515bd7c8fc671f47875e3ccc" } +yaml_serde = "0.10" zeroize = { version = "1", features = ["zeroize_derive", "serde"] } -zip = "0.6" +zip = { version = "6.0", default-features = false, features = ["deflate"] } zstd = "0.13" [profile.maxperf] @@ -290,4 +274,5 @@ inherits = "release" debug = true [patch.crates-io] -quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "681f413312404ab6e51f0b46f39b0075c6f4ebfd" } +quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } + diff --git a/Makefile b/Makefile index 9d08c3ebe1..9246b33999 100644 --- a/Makefile +++ b/Makefile @@ -36,8 +36,12 @@ PROFILE ?= release RECENT_FORKS_BEFORE_GLOAS=electra fulu # List of all recent hard forks. This list is used to set env variables for http_api tests +# Include phase0 to test the code paths in sync that are pre blobs RECENT_FORKS=electra fulu gloas +# For network tests include phase0 to cover genesis syncing (blocks without blobs or columns) +TEST_NETWORK_FORKS=phase0 $(RECENT_FORKS_BEFORE_GLOAS) + # Extra flags for Cargo CARGO_INSTALL_EXTRA_FLAGS?= @@ -203,11 +207,10 @@ run-ef-tests: ./$(EF_TESTS)/check_all_files_accessed.py $(EF_TESTS)/.accessed_file_log.txt $(EF_TESTS)/consensus-spec-tests # Run the tests in the `beacon_chain` crate for all known forks. -# TODO(EIP-7732) Extend to support gloas by using RECENT_FORKS instead -test-beacon-chain: $(patsubst %,test-beacon-chain-%,$(RECENT_FORKS_BEFORE_GLOAS)) +test-beacon-chain: $(patsubst %,test-beacon-chain-%,$(RECENT_FORKS)) test-beacon-chain-%: - env FORK_NAME=$* cargo nextest run --release --features "fork_from_env,slasher/lmdb,$(TEST_FEATURES)" -p beacon_chain + env FORK_NAME=$* cargo nextest run --release --features "fork_from_env,slasher/lmdb,$(TEST_FEATURES)" -p beacon_chain --no-fail-fast # Run the tests in the `http_api` crate for recent forks. test-http-api: $(patsubst %,test-http-api-%,$(RECENT_FORKS_BEFORE_GLOAS)) @@ -226,12 +229,15 @@ test-op-pool-%: # Run the tests in the `network` crate for all known forks. # TODO(EIP-7732) Extend to support gloas by using RECENT_FORKS instead -test-network: $(patsubst %,test-network-%,$(RECENT_FORKS_BEFORE_GLOAS)) +test-network: $(patsubst %,test-network-%,$(TEST_NETWORK_FORKS)) test-network-%: - env FORK_NAME=$* cargo nextest run --release \ - --features "fork_from_env,$(TEST_FEATURES)" \ + env FORK_NAME=$* cargo nextest run --no-fail-fast --release \ + --features "fork_from_env,fake_crypto,$(TEST_FEATURES)" \ -p network + env FORK_NAME=$* cargo nextest run --no-fail-fast --release \ + --features "fork_from_env,$(TEST_FEATURES)" \ + -p network crypto_on # Run the tests in the `slasher` crate for all supported database backends. test-slasher: @@ -314,8 +320,8 @@ make-ef-tests-nightly: # Verifies that crates compile with fuzzing features enabled arbitrary-fuzz: - cargo check -p state_processing --features arbitrary-fuzz,$(TEST_FEATURES) - cargo check -p slashing_protection --features arbitrary-fuzz,$(TEST_FEATURES) + cargo check -p state_processing --features arbitrary,$(TEST_FEATURES) + cargo check -p slashing_protection --features arbitrary,$(TEST_FEATURES) # Runs cargo audit (Audit Cargo.lock files for crates with security vulnerabilities reported to the RustSec Advisory Database) audit: install-audit audit-CI @@ -324,7 +330,7 @@ install-audit: cargo install --force cargo-audit audit-CI: - cargo audit + cargo audit --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 # Runs cargo deny (check for banned crates, duplicate versions, and source restrictions) deny: install-deny deny-CI @@ -343,8 +349,20 @@ vendor: udeps: cargo +$(PINNED_NIGHTLY) udeps --tests --all-targets --release --features "$(TEST_FEATURES)" +# Checks Cargo.toml files for unencrypted HTTP links +insecure-deps: + @ BAD_LINKS=$$(find . -name Cargo.toml | xargs grep -n "http://" || true); \ + if [ -z "$$BAD_LINKS" ]; then echo "No insecure HTTP links found"; \ + else echo "$$BAD_LINKS"; echo "Using plain HTTP in Cargo.toml files is forbidden"; exit 1; fi + # Performs a `cargo` clean and cleans the `ef_tests` directory. clean: cargo clean make -C $(EF_TESTS) clean make -C $(STATE_TRANSITION_VECTORS) clean + +# Installs git hooks from .githooks/ directory +install-hooks: + @ln -sf ../../.githooks/pre-commit .git/hooks/pre-commit + @chmod +x .githooks/pre-commit + @echo "Git hooks installed. Pre-commit hook runs 'cargo fmt --check'." diff --git a/account_manager/Cargo.toml b/account_manager/Cargo.toml index 8dd50cbc6e..05e6f12554 100644 --- a/account_manager/Cargo.toml +++ b/account_manager/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "account_manager" version = { workspace = true } -authors = [ - "Paul Hauner <paul@paulhauner.com>", - "Luke Anderson <luke@sigmaprime.io>", -] +authors = ["Paul Hauner <paul@paulhauner.com>", "Luke Anderson <luke@sigmaprime.io>"] edition = { workspace = true } [dependencies] diff --git a/account_manager/src/validator/exit.rs b/account_manager/src/validator/exit.rs index 5ea77f284e..b0b5c8a59d 100644 --- a/account_manager/src/validator/exit.rs +++ b/account_manager/src/validator/exit.rs @@ -102,7 +102,7 @@ pub fn cli_run<E: EthSpec>(matches: &ArgMatches, env: Environment<E>) -> Result< let client = BeaconNodeHttpClient::new( SensitiveUrl::parse(&server_url) .map_err(|e| format!("Failed to parse beacon http server: {:?}", e))?, - Timeouts::set_all(Duration::from_secs(env.eth2_config.spec.seconds_per_slot)), + Timeouts::set_all(env.eth2_config.spec.get_slot_duration()), ); let eth2_network_config = env @@ -230,7 +230,7 @@ async fn publish_voluntary_exit<E: EthSpec>( loop { // Sleep for a slot duration and then check if voluntary exit was processed // by checking the validator status. - sleep(Duration::from_secs(spec.seconds_per_slot)).await; + sleep(spec.get_slot_duration()).await; let validator_data = get_validator_data(client, &keypair.pk).await?; match validator_data.status { @@ -251,7 +251,9 @@ async fn publish_voluntary_exit<E: EthSpec>( eprintln!("Please keep your validator running till exit epoch"); eprintln!( "Exit epoch in approximately {} secs", - (exit_epoch - current_epoch) * spec.seconds_per_slot * E::slots_per_epoch() + (exit_epoch - current_epoch) + * spec.get_slot_duration().as_secs() + * E::slots_per_epoch() ); break; } @@ -350,7 +352,7 @@ fn get_current_epoch<E: EthSpec>(genesis_time: u64, spec: &ChainSpec) -> Option< let slot_clock = SystemTimeSlotClock::new( spec.genesis_slot, Duration::from_secs(genesis_time), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); slot_clock.now().map(|s| s.epoch(E::slots_per_epoch())) } diff --git a/account_manager/src/validator/mod.rs b/account_manager/src/validator/mod.rs index 5a6c9439a6..2a92ad2d37 100644 --- a/account_manager/src/validator/mod.rs +++ b/account_manager/src/validator/mod.rs @@ -28,6 +28,7 @@ pub fn cli_app() -> Command { "The path to search for validator directories. \ Defaults to ~/.lighthouse/{network}/validators", ) + .global(true) .action(ArgAction::Set) .conflicts_with("datadir"), ) diff --git a/beacon_node/Cargo.toml b/beacon_node/Cargo.toml index 5352814dd5..ebefa6a451 100644 --- a/beacon_node/Cargo.toml +++ b/beacon_node/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "beacon_node" version = { workspace = true } -authors = [ - "Paul Hauner <paul@paulhauner.com>", - "Age Manning <Age@AgeManning.com", -] +authors = ["Paul Hauner <paul@paulhauner.com>", "Age Manning <Age@AgeManning.com"] edition = { workspace = true } [lib] @@ -12,10 +9,10 @@ name = "beacon_node" path = "src/lib.rs" [features] -write_ssz_files = [ - "beacon_chain/write_ssz_files", -] # Writes debugging .ssz files to /tmp during block processing. -testing = [] # Enables testing-only CLI flags +# Writes debugging .ssz files to /tmp during block processing. +write_ssz_files = ["beacon_chain/write_ssz_files"] +# Enables testing-only CLI flags. +testing = [] [dependencies] account_utils = { workspace = true } diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index 734cfdf32b..a06db8934b 100644 --- a/beacon_node/beacon_chain/Cargo.toml +++ b/beacon_node/beacon_chain/Cargo.toml @@ -8,9 +8,12 @@ autotests = false # using a single test binary compiles faster [features] default = ["participation_metrics"] -write_ssz_files = [] # Writes debugging .ssz files to /tmp during block processing. -participation_metrics = [] # Exposes validator participation metrics to Prometheus. -fork_from_env = [] # Initialise the harness chain spec from the FORK_NAME env variable +# Writes debugging .ssz files to /tmp during block processing. +write_ssz_files = [] +# Exposes validator participation metrics to Prometheus. +participation_metrics = [] +# Initialise the harness chain spec from the FORK_NAME env variable +fork_from_env = [] portable = ["bls/supranational-portable"] test_backfill = [] @@ -19,7 +22,7 @@ alloy-primitives = { workspace = true } bitvec = { workspace = true } bls = { workspace = true } educe = { workspace = true } -eth2 = { workspace = true, features = ["lighthouse"] } +eth2 = { workspace = true, features = ["lighthouse", "network"] } eth2_network_config = { workspace = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -34,7 +37,6 @@ hex = { workspace = true } int_to_bytes = { workspace = true } itertools = { workspace = true } kzg = { workspace = true } -lighthouse_tracing = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } lru = { workspace = true } diff --git a/beacon_node/beacon_chain/benches/benches.rs b/beacon_node/beacon_chain/benches/benches.rs index de3ced3be1..e71a19d8c1 100644 --- a/beacon_node/beacon_chain/benches/benches.rs +++ b/beacon_node/beacon_chain/benches/benches.rs @@ -1,14 +1,15 @@ +use std::hint::black_box; use std::sync::Arc; use beacon_chain::kzg_utils::{blobs_to_data_column_sidecars, reconstruct_data_columns}; use beacon_chain::test_utils::get_kzg; -use criterion::{Criterion, black_box, criterion_group, criterion_main}; +use criterion::{Criterion, criterion_group, criterion_main}; use bls::Signature; use kzg::{KzgCommitment, KzgProof}; use types::{ BeaconBlock, BeaconBlockFulu, Blob, BlobsList, ChainSpec, EmptyBlock, EthSpec, KzgProofs, - MainnetEthSpec, SignedBeaconBlock, beacon_block_body::KzgCommitments, + MainnetEthSpec, SignedBeaconBlock, kzg_ext::KzgCommitments, }; fn create_test_block_and_blobs<E: EthSpec>( diff --git a/beacon_node/beacon_chain/src/attestation_rewards.rs b/beacon_node/beacon_chain/src/attestation_rewards.rs index 554cd431b3..b25dd1f154 100644 --- a/beacon_node/beacon_chain/src/attestation_rewards.rs +++ b/beacon_node/beacon_chain/src/attestation_rewards.rs @@ -320,7 +320,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { ) .into_values() .collect::<Vec<IdealAttestationRewards>>(); - ideal_rewards.sort_by(|a, b| a.effective_balance.cmp(&b.effective_balance)); + ideal_rewards.sort_by_key(|a| a.effective_balance); Ok(StandardAttestationRewards { ideal_rewards, diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index faa396966f..f35de59e1f 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -61,8 +61,9 @@ use tracing::{debug, error}; use tree_hash::TreeHash; use types::{ Attestation, AttestationData, AttestationRef, BeaconCommittee, - BeaconStateError::NoCommitteeFound, ChainSpec, CommitteeIndex, Epoch, EthSpec, Hash256, - IndexedAttestation, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, SubnetId, + BeaconStateError::NoCommitteeFound, ChainSpec, CommitteeIndex, Epoch, EthSpec, ForkName, + Hash256, IndexedAttestation, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, + SubnetId, }; pub use batch::{batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations}; @@ -160,6 +161,12 @@ pub enum Error { /// /// The peer has sent an invalid message. CommitteeIndexNonZero(usize), + /// The validator index is set to an invalid value after Gloas. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + CommitteeIndexInvalid, /// The `attestation.data.beacon_block_root` block is unknown. /// /// ## Peer scoring @@ -507,11 +514,6 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { chain: &BeaconChain<T>, ) -> Result<Self, Error> { Self::verify_slashable(signed_aggregate, chain) - .inspect(|verified_aggregate| { - if let Some(slasher) = chain.slasher.as_ref() { - slasher.accept_attestation(verified_aggregate.indexed_attestation.clone()); - } - }) .map_err(|slash_info| process_slash_info(slash_info, chain)) } @@ -550,8 +552,12 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { } .tree_hash_root(); + let fork_name = chain + .spec + .fork_name_at_slot::<T::EthSpec>(attestation.data().slot); + // [New in Electra:EIP7549] - verify_committee_index(attestation)?; + verify_committee_index(attestation, fork_name)?; if chain .observed_attestations @@ -595,6 +601,17 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { // attestation and do not delay consideration for later. let head_block = verify_head_block_is_known(chain, attestation.data(), None)?; + // [New in Gloas]: If the attested block is from the same slot as the attestation, + // index must be 0. + if fork_name.gloas_enabled() + && head_block.slot == attestation.data().slot + && attestation.data().index != 0 + { + return Err(Error::CommitteeIndexNonZero( + attestation.data().index as usize, + )); + } + // Check the attestation target root is consistent with the head root. // // This check is not in the specification, however we guard against it since it opens us up @@ -871,7 +888,12 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { let fork_name = chain .spec .fork_name_at_slot::<T::EthSpec>(attestation.data.slot); - if fork_name.electra_enabled() { + if fork_name.gloas_enabled() { + // [New in Gloas] + if attestation.data.index >= 2 { + return Err(Error::CommitteeIndexInvalid); + } + } else if fork_name.electra_enabled() { // [New in Electra:EIP7549] if attestation.data.index != 0 { return Err(Error::CommitteeIndexNonZero( @@ -890,6 +912,17 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { chain.config.import_max_skip_slots, )?; + // [New in Gloas]: If the attested block is from the same slot as the attestation, + // index must be 0. + if fork_name.gloas_enabled() + && head_block.slot == attestation.data.slot + && attestation.data.index != 0 + { + return Err(Error::CommitteeIndexNonZero( + attestation.data.index as usize, + )); + } + // Check the attestation target root is consistent with the head root. verify_attestation_target_root::<T::EthSpec>(&head_block, &attestation.data)?; @@ -933,11 +966,6 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { chain: &BeaconChain<T>, ) -> Result<Self, Error> { Self::verify_slashable(attestation, subnet_id, chain) - .inspect(|verified_unaggregated| { - if let Some(slasher) = chain.slasher.as_ref() { - slasher.accept_attestation(verified_unaggregated.indexed_attestation.clone()); - } - }) .map_err(|slash_info| process_slash_info(slash_info, chain)) } @@ -1404,7 +1432,10 @@ pub fn verify_signed_aggregate_signatures<T: BeaconChainTypes>( /// Verify that the `attestation` committee index is properly set for the attestation's fork. /// This function will only apply verification post-Electra. -pub fn verify_committee_index<E: EthSpec>(attestation: AttestationRef<E>) -> Result<(), Error> { +pub fn verify_committee_index<E: EthSpec>( + attestation: AttestationRef<E>, + fork_name: ForkName, +) -> Result<(), Error> { if let Ok(committee_bits) = attestation.committee_bits() { // Check to ensure that the attestation is for a single committee. let num_committee_bits = get_committee_indices::<E>(committee_bits); @@ -1414,11 +1445,18 @@ pub fn verify_committee_index<E: EthSpec>(attestation: AttestationRef<E>) -> Res )); } - // Ensure the attestation index is set to zero post Electra. - if attestation.data().index != 0 { - return Err(Error::CommitteeIndexNonZero( - attestation.data().index as usize, - )); + // Ensure the attestation index is valid for the fork. + let index = attestation.data().index; + if fork_name.gloas_enabled() { + // [New in Gloas]: index must be < 2. + if index >= 2 { + return Err(Error::CommitteeIndexInvalid); + } + } else { + // [New in Electra:EIP7549]: index must be 0. + if index != 0 { + return Err(Error::CommitteeIndexNonZero(index as usize)); + } } } Ok(()) diff --git a/beacon_node/beacon_chain/src/attester_cache.rs b/beacon_node/beacon_chain/src/attester_cache.rs deleted file mode 100644 index 26a3389812..0000000000 --- a/beacon_node/beacon_chain/src/attester_cache.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! This module provides the `AttesterCache`, a cache designed for reducing state-reads when -//! validators produce `AttestationData`. -//! -//! This cache is required *as well as* the `ShufflingCache` since the `ShufflingCache` does not -//! provide any information about the `state.current_justified_checkpoint`. It is not trivial to add -//! the justified checkpoint to the `ShufflingCache` since that cache is keyed by shuffling decision -//! root, which is not suitable for the justified checkpoint. Whilst we can know the shuffling for -//! epoch `n` during `n - 1`, we *cannot* know the justified checkpoint. Instead, we *must* perform -//! `per_epoch_processing` to transform the state from epoch `n - 1` to epoch `n` so that rewards -//! and penalties can be computed and the `state.current_justified_checkpoint` can be updated. - -use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use fixed_bytes::FixedBytesExtended; -use parking_lot::RwLock; -use state_processing::state_advance::{Error as StateAdvanceError, partial_state_advance}; -use std::collections::HashMap; -use std::ops::Range; -use types::{ - BeaconState, BeaconStateError, ChainSpec, Checkpoint, Epoch, EthSpec, Hash256, RelativeEpoch, - Slot, - attestation::AttestationError, - beacon_state::{ - compute_committee_index_in_epoch, compute_committee_range_in_epoch, epoch_committee_count, - }, -}; - -type JustifiedCheckpoint = Checkpoint; -type CommitteeLength = usize; -type CommitteeIndex = u64; -type CacheHashMap = HashMap<AttesterCacheKey, AttesterCacheValue>; - -/// The maximum number of `AttesterCacheValues` to be kept in memory. -/// -/// Each `AttesterCacheValues` is very small (~16 bytes) and the cache will generally be kept small -/// by pruning on finality. -/// -/// The value provided here is much larger than will be used during ideal network conditions, -/// however we make it large since the values are so small. -const MAX_CACHE_LEN: usize = 1_024; - -#[derive(Debug)] -pub enum Error { - BeaconState(BeaconStateError), - // Boxed to avoid an infinite-size recursion issue. - BeaconChain(Box<BeaconChainError>), - MissingBeaconState(Hash256), - FailedToTransitionState(StateAdvanceError), - CannotAttestToFutureState { - state_slot: Slot, - request_slot: Slot, - }, - /// Indicates a cache inconsistency. - WrongEpoch { - request_epoch: Epoch, - epoch: Epoch, - }, - InvalidCommitteeIndex { - committee_index: u64, - }, - /// Indicates an inconsistency with the beacon state committees. - InverseRange { - range: Range<usize>, - }, - AttestationError(AttestationError), -} - -impl From<BeaconStateError> for Error { - fn from(e: BeaconStateError) -> Self { - Error::BeaconState(e) - } -} - -impl From<BeaconChainError> for Error { - fn from(e: BeaconChainError) -> Self { - Error::BeaconChain(Box::new(e)) - } -} - -/// Stores the minimal amount of data required to compute the committee length for any committee at any -/// slot in a given `epoch`. -pub struct CommitteeLengths { - /// The `epoch` to which the lengths pertain. - epoch: Epoch, - /// The length of the shuffling in `self.epoch`. - active_validator_indices_len: usize, -} - -impl CommitteeLengths { - /// Instantiate `Self` using `state.current_epoch()`. - pub fn new<E: EthSpec>(state: &BeaconState<E>, spec: &ChainSpec) -> Result<Self, Error> { - let active_validator_indices_len = if let Ok(committee_cache) = - state.committee_cache(RelativeEpoch::Current) - { - committee_cache.active_validator_indices().len() - } else { - // Building the cache like this avoids taking a mutable reference to `BeaconState`. - let committee_cache = state.initialize_committee_cache(state.current_epoch(), spec)?; - committee_cache.active_validator_indices().len() - }; - - Ok(Self { - epoch: state.current_epoch(), - active_validator_indices_len, - }) - } - - /// Get the count of committees per each slot of `self.epoch`. - pub fn get_committee_count_per_slot<E: EthSpec>( - &self, - spec: &ChainSpec, - ) -> Result<usize, Error> { - E::get_committee_count_per_slot(self.active_validator_indices_len, spec).map_err(Into::into) - } - - /// Get the length of the committee at the given `slot` and `committee_index`. - pub fn get_committee_length<E: EthSpec>( - &self, - slot: Slot, - committee_index: CommitteeIndex, - spec: &ChainSpec, - ) -> Result<CommitteeLength, Error> { - let slots_per_epoch = E::slots_per_epoch(); - let request_epoch = slot.epoch(slots_per_epoch); - - // Sanity check. - if request_epoch != self.epoch { - return Err(Error::WrongEpoch { - request_epoch, - epoch: self.epoch, - }); - } - - let slots_per_epoch = slots_per_epoch as usize; - let committees_per_slot = self.get_committee_count_per_slot::<E>(spec)?; - let index_in_epoch = compute_committee_index_in_epoch( - slot, - slots_per_epoch, - committees_per_slot, - committee_index as usize, - ); - let range = compute_committee_range_in_epoch( - epoch_committee_count(committees_per_slot, slots_per_epoch), - index_in_epoch, - self.active_validator_indices_len, - ) - .ok_or(Error::InvalidCommitteeIndex { committee_index })?; - - range - .end - .checked_sub(range.start) - .ok_or(Error::InverseRange { range }) - } -} - -/// Provides the following information for some epoch: -/// -/// - The `state.current_justified_checkpoint` value. -/// - The committee lengths for all indices and slots. -/// -/// These values are used during attestation production. -pub struct AttesterCacheValue { - current_justified_checkpoint: Checkpoint, - committee_lengths: CommitteeLengths, -} - -impl AttesterCacheValue { - /// Instantiate `Self` using `state.current_epoch()`. - pub fn new<E: EthSpec>(state: &BeaconState<E>, spec: &ChainSpec) -> Result<Self, Error> { - let current_justified_checkpoint = state.current_justified_checkpoint(); - let committee_lengths = CommitteeLengths::new(state, spec)?; - Ok(Self { - current_justified_checkpoint, - committee_lengths, - }) - } - - /// Get the justified checkpoint and committee length for some `slot` and `committee_index`. - fn get<E: EthSpec>( - &self, - slot: Slot, - committee_index: CommitteeIndex, - spec: &ChainSpec, - ) -> Result<(JustifiedCheckpoint, CommitteeLength), Error> { - self.committee_lengths - .get_committee_length::<E>(slot, committee_index, spec) - .map(|committee_length| (self.current_justified_checkpoint, committee_length)) - } -} - -/// The `AttesterCacheKey` is fundamentally the same thing as the proposer shuffling decision root, -/// however here we use it as an identity for both of the following values: -/// -/// 1. The `state.current_justified_checkpoint`. -/// 2. The attester shuffling. -/// -/// This struct relies upon the premise that the `state.current_justified_checkpoint` in epoch `n` -/// is determined by the root of the latest block in epoch `n - 1`. Notably, this is identical to -/// how the proposer shuffling is keyed in `BeaconProposerCache`. -/// -/// It is also safe, but not maximally efficient, to key the attester shuffling with the same -/// strategy. For better shuffling keying strategies, see the `ShufflingCache`. -#[derive(Eq, PartialEq, Hash, Clone, Copy)] -pub struct AttesterCacheKey { - /// The epoch from which the justified checkpoint should be observed. - /// - /// Attestations which use `self.epoch` as `target.epoch` should use this key. - epoch: Epoch, - /// The root of the block at the last slot of `self.epoch - 1`. - decision_root: Hash256, -} - -impl AttesterCacheKey { - /// Instantiate `Self` to key `state.current_epoch()`. - /// - /// The `latest_block_root` should be the latest block that has been applied to `state`. This - /// parameter is required since the state does not store the block root for any block with the - /// same slot as `state.slot()`. - /// - /// ## Errors - /// - /// May error if `epoch` is out of the range of `state.block_roots`. - pub fn new<E: EthSpec>( - epoch: Epoch, - state: &BeaconState<E>, - latest_block_root: Hash256, - ) -> Result<Self, Error> { - let slots_per_epoch = E::slots_per_epoch(); - let decision_slot = epoch.start_slot(slots_per_epoch).saturating_sub(1_u64); - - let decision_root = if decision_slot.epoch(slots_per_epoch) == epoch { - // This scenario is only possible during the genesis epoch. In this scenario, all-zeros - // is used as an alias to the genesis block. - Hash256::zero() - } else if epoch > state.current_epoch() { - // If the requested epoch is higher than the current epoch, the latest block will always - // be the decision root. - latest_block_root - } else { - *state.get_block_root(decision_slot)? - }; - - Ok(Self { - epoch, - decision_root, - }) - } -} - -/// Provides a cache for the justified checkpoint and committee length when producing an -/// attestation. -/// -/// See the module-level documentation for more information. -#[derive(Default)] -pub struct AttesterCache { - cache: RwLock<CacheHashMap>, -} - -impl AttesterCache { - /// Get the justified checkpoint and committee length for the `slot` and `committee_index` in - /// the state identified by the cache `key`. - pub fn get<E: EthSpec>( - &self, - key: &AttesterCacheKey, - slot: Slot, - committee_index: CommitteeIndex, - spec: &ChainSpec, - ) -> Result<Option<(JustifiedCheckpoint, CommitteeLength)>, Error> { - self.cache - .read() - .get(key) - .map(|cache_item| cache_item.get::<E>(slot, committee_index, spec)) - .transpose() - } - - /// Cache the `state.current_epoch()` values if they are not already present in the state. - pub fn maybe_cache_state<E: EthSpec>( - &self, - state: &BeaconState<E>, - latest_block_root: Hash256, - spec: &ChainSpec, - ) -> Result<(), Error> { - let key = AttesterCacheKey::new(state.current_epoch(), state, latest_block_root)?; - let mut cache = self.cache.write(); - if !cache.contains_key(&key) { - let cache_item = AttesterCacheValue::new(state, spec)?; - Self::insert_respecting_max_len(&mut cache, key, cache_item); - } - Ok(()) - } - - /// Read the state identified by `state_root` from the database, advance it to the required - /// slot, use it to prime the cache and return the values for the provided `slot` and - /// `committee_index`. - /// - /// ## Notes - /// - /// This function takes a write-lock on the internal cache. Prefer attempting a `Self::get` call - /// before running this function as `Self::get` only takes a read-lock and is therefore less - /// likely to create contention. - pub fn load_and_cache_state<T: BeaconChainTypes>( - &self, - state_root: Hash256, - key: AttesterCacheKey, - slot: Slot, - committee_index: CommitteeIndex, - chain: &BeaconChain<T>, - ) -> Result<(JustifiedCheckpoint, CommitteeLength), Error> { - let spec = &chain.spec; - let slots_per_epoch = T::EthSpec::slots_per_epoch(); - let epoch = slot.epoch(slots_per_epoch); - - // Take a write-lock on the cache before starting the state read. - // - // Whilst holding the write-lock during the state read will create contention, it prevents - // the scenario where multiple requests from separate threads cause duplicate state reads. - let mut cache = self.cache.write(); - - // Try the cache to see if someone has already primed it between the time the function was - // called and when the cache write-lock was obtained. This avoids performing duplicate state - // reads. - if let Some(value) = cache - .get(&key) - .map(|cache_item| cache_item.get::<T::EthSpec>(slot, committee_index, spec)) - .transpose()? - { - return Ok(value); - } - - // We use `cache_state = true` here because if we are attesting to the state it's likely - // to be recent and useful for other things. - let mut state: BeaconState<T::EthSpec> = chain - .get_state(&state_root, None, true)? - .ok_or(Error::MissingBeaconState(state_root))?; - - if state.slot() > slot { - // This indicates an internal inconsistency. - return Err(Error::CannotAttestToFutureState { - state_slot: state.slot(), - request_slot: slot, - }); - } else if state.current_epoch() < epoch { - // Only perform a "partial" state advance since we do not require the state roots to be - // accurate. - partial_state_advance( - &mut state, - Some(state_root), - epoch.start_slot(slots_per_epoch), - spec, - ) - .map_err(Error::FailedToTransitionState)?; - state.build_committee_cache(RelativeEpoch::Current, spec)?; - } - - let cache_item = AttesterCacheValue::new(&state, spec)?; - let value = cache_item.get::<T::EthSpec>(slot, committee_index, spec)?; - Self::insert_respecting_max_len(&mut cache, key, cache_item); - Ok(value) - } - - /// Insert a value to `cache`, ensuring it does not exceed the maximum length. - /// - /// If the cache is already full, the item with the lowest epoch will be removed. - fn insert_respecting_max_len( - cache: &mut CacheHashMap, - key: AttesterCacheKey, - value: AttesterCacheValue, - ) { - while cache.len() >= MAX_CACHE_LEN { - if let Some(oldest) = cache.keys().copied().min_by_key(|key| key.epoch) { - cache.remove(&oldest); - } else { - break; - } - } - - cache.insert(key, value); - } - - /// Remove all entries where the `key.epoch` is lower than the given `epoch`. - /// - /// Generally, the provided `epoch` should be the finalized epoch. - pub fn prune_below(&self, epoch: Epoch) { - self.cache.write().retain(|target, _| target.epoch >= epoch); - } -} diff --git a/beacon_node/beacon_chain/src/beacon_block_reward.rs b/beacon_node/beacon_chain/src/beacon_block_reward.rs index ac4ed2ab67..88686696ed 100644 --- a/beacon_node/beacon_chain/src/beacon_block_reward.rs +++ b/beacon_node/beacon_chain/src/beacon_block_reward.rs @@ -135,7 +135,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { state .get_validator(proposer_slashing.proposer_index() as usize)? .effective_balance - .safe_div(self.spec.whistleblower_reward_quotient_for_state(state))?, + .safe_div(state.get_whistleblower_reward_quotient(&self.spec))?, )?; } @@ -157,7 +157,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { state .get_validator(attester_index as usize)? .effective_balance - .safe_div(self.spec.whistleblower_reward_quotient_for_state(state))?, + .safe_div(state.get_whistleblower_reward_quotient(&self.spec))?, )?; } } diff --git a/beacon_node/beacon_chain/src/beacon_block_streamer.rs b/beacon_node/beacon_chain/src/beacon_block_streamer.rs index 95144e65ec..33fc9be4a0 100644 --- a/beacon_node/beacon_chain/src/beacon_block_streamer.rs +++ b/beacon_node/beacon_chain/src/beacon_block_streamer.rs @@ -687,7 +687,6 @@ mod tests { use crate::beacon_block_streamer::{BeaconBlockStreamer, CheckCaches}; use crate::test_utils::{BeaconChainHarness, EphemeralHarnessType, test_spec}; use bls::Keypair; - use execution_layer::test_utils::Block; use fixed_bytes::FixedBytesExtended; use std::sync::Arc; use std::sync::LazyLock; @@ -720,8 +719,8 @@ mod tests { #[tokio::test] async fn check_all_blocks_from_altair_to_fulu() { let slots_per_epoch = MinimalEthSpec::slots_per_epoch() as usize; - let num_epochs = 14; - let bellatrix_fork_epoch = 2usize; + let num_epochs = 12; + let bellatrix_fork_epoch = 0usize; let capella_fork_epoch = 4usize; let deneb_fork_epoch = 6usize; let electra_fork_epoch = 8usize; @@ -737,34 +736,12 @@ mod tests { spec.electra_fork_epoch = Some(Epoch::new(electra_fork_epoch as u64)); spec.eip7805_fork_epoch = Some(Epoch::new(eip7805_fork_epoch as u64)); spec.fulu_fork_epoch = Some(Epoch::new(fulu_fork_epoch as u64)); + spec.gloas_fork_epoch = None; let spec = Arc::new(spec); let harness = get_harness(VALIDATOR_COUNT, spec.clone()); - // go to bellatrix fork - harness - .extend_slots(bellatrix_fork_epoch * slots_per_epoch) - .await; - // extend half an epoch - harness.extend_slots(slots_per_epoch / 2).await; - // trigger merge - harness - .execution_block_generator() - .move_to_terminal_block() - .expect("should move to terminal block"); - let timestamp = harness.get_timestamp_at_slot() + harness.spec.seconds_per_slot; - harness - .execution_block_generator() - .modify_last_block(|block| { - if let Block::PoW(terminal_block) = block { - terminal_block.timestamp = timestamp; - } - }); - // finish out merge epoch - harness.extend_slots(slots_per_epoch / 2).await; // finish rest of epochs - harness - .extend_slots((num_epochs - 1 - bellatrix_fork_epoch) * slots_per_epoch) - .await; + harness.extend_slots(num_epochs * slots_per_epoch).await; let head = harness.chain.head_snapshot(); let state = &head.beacon_state; diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 8494a54b44..3bde5abf8e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -3,21 +3,17 @@ use crate::attestation_verification::{ VerifiedUnaggregatedAttestation, batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations, }; -use crate::attester_cache::{AttesterCache, AttesterCacheKey}; use crate::beacon_block_streamer::{BeaconBlockStreamer, CheckCaches}; -use crate::beacon_proposer_cache::{ - BeaconProposerCache, EpochBlockProposers, ensure_state_can_determine_proposers_for_epoch, -}; +use crate::beacon_proposer_cache::{BeaconProposerCache, EpochBlockProposers}; use crate::blob_verification::{GossipBlobError, GossipVerifiedBlob}; use crate::block_times_cache::BlockTimesCache; -use crate::block_verification::POS_PANDA_BANNER; use crate::block_verification::{ BlockError, ExecutionPendingBlock, GossipVerifiedBlock, IntoExecutionPendingBlock, check_block_is_finalized_checkpoint_or_descendant, check_block_relevancy, signature_verify_chain_segment, verify_header_signature, }; use crate::block_verification_types::{ - AsBlock, AvailableExecutedBlock, BlockImportData, ExecutedBlock, RpcBlock, + AsBlock, AvailableExecutedBlock, BlockImportData, ExecutedBlock, RangeSyncBlock, }; pub use crate::canonical_head::CanonicalHead; use crate::chain_config::ChainConfig; @@ -26,13 +22,19 @@ use crate::data_availability_checker::{ Availability, AvailabilityCheckError, AvailableBlock, AvailableBlockData, DataAvailabilityChecker, DataColumnReconstructionResult, }; -use crate::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; +use crate::data_column_verification::{ + GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, + GossipVerifiedPartialDataColumnHeader, KzgVerifiedCustodyPartialDataColumn, + KzgVerifiedPartialDataColumn, PartialColumnVerificationResult, + validate_partial_data_column_sidecar_for_gossip, +}; use crate::early_attester_cache::EarlyAttesterCache; +use crate::envelope_times_cache::EnvelopeTimesCache; use crate::errors::{BeaconChainError as Error, BlockProductionError}; use crate::events::ServerSentEventHandler; use crate::execution_payload::{NotifyExecutionLayer, PreparePayloadHandle, get_execution_payload}; use crate::fetch_blobs::EngineGetBlobsOutput; -use crate::fork_choice_signal::{ForkChoiceSignalRx, ForkChoiceSignalTx, ForkChoiceWaitResult}; +use crate::fork_choice_signal::{ForkChoiceSignalRx, ForkChoiceSignalTx}; use crate::graffiti_calculator::{GraffitiCalculator, GraffitiSettings}; use crate::inclusion_list_verification::{GossipInclusionListError, GossipVerifiedInclusionList}; use crate::kzg_utils::reconstruct_blobs; @@ -52,23 +54,30 @@ use crate::observed_aggregates::{ Error as AttestationObservationError, ObservedAggregateAttestations, ObservedSyncContributions, }; use crate::observed_attesters::{ - ObservedAggregators, ObservedAttesters, ObservedSyncAggregators, ObservedSyncContributors, + ObservedAggregators, ObservedAttesters, ObservedPayloadAttesters, ObservedSyncAggregators, + ObservedSyncContributors, }; use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; +use crate::partial_data_column_assembler::PartialMergeResult; +use crate::payload_attestation_verification::VerifiedPayloadAttestationMessage; +use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; +#[cfg(not(test))] +use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; +use crate::pending_payload_envelopes::PendingPayloadEnvelopes; use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::persist_custody_context; use crate::persisted_fork_choice::PersistedForkChoice; use crate::pre_finalization_cache::PreFinalizationBlockCache; +use crate::proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache; use crate::shuffling_cache::{BlockShufflingIds, ShufflingCache}; use crate::sync_committee_verification::{ Error as SyncCommitteeError, VerifiedSyncCommitteeMessage, VerifiedSyncContribution, }; use crate::validator_monitor::{ HISTORIC_EPOCHS as VALIDATOR_MONITOR_HISTORIC_EPOCHS, ValidatorMonitor, get_slot_delay_ms, - timestamp_now, }; use crate::validator_pubkey_cache::ValidatorPubkeyCache; use crate::{ @@ -78,8 +87,8 @@ use crate::{ use bls::{PublicKey, PublicKeyBytes, Signature}; use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{ - EventKind, SseBlobSidecar, SseBlock, SseDataColumnSidecar, SseExtendedPayloadAttributes, - SseInclusionList, + EventKind, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, + SseExtendedPayloadAttributes, SseHead, SseInclusionList, }; use execution_layer::{ BlockProposalContents, BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, @@ -111,8 +120,8 @@ use state_processing::{ epoch_cache::initialize_epoch_cache, per_block_processing, per_block_processing::{ - VerifySignatures, errors::AttestationValidationError, get_expected_withdrawals, - verify_attestation_for_block_inclusion, + VerifySignatures, apply_parent_execution_payload, errors::AttestationValidationError, + get_expected_withdrawals, verify_attestation_for_block_inclusion, }, per_slot_processing, state_advance::{complete_state_advance, partial_state_advance}, @@ -132,17 +141,16 @@ use store::{ }; use task_executor::{RayonPoolType, ShutdownReason, TaskExecutor}; use tokio_stream::Stream; -use tracing::{Span, debug, debug_span, error, info, info_span, instrument, trace, warn}; +use tracing::{debug, debug_span, error, info, info_span, instrument, trace, warn}; use tree_hash::TreeHash; -use types::blob_sidecar::FixedBlobSidecarList; -use types::data_column_sidecar::ColumnIndex; -use types::payload::BlockProductionVersion; +use types::data::{ColumnIndex, FixedBlobSidecarList}; +use types::execution::BlockProductionVersion; use types::*; pub type ForkChoiceError = fork_choice::Error<crate::ForkChoiceStoreError>; /// Alias to appease clippy. -type HashBlockTuple<E> = (Hash256, RpcBlock<E>); +type HashBlockTuple<E> = (Hash256, RangeSyncBlock<E>); // These keys are all zero because they get stored in different columns, see `DBColumn` type. pub const BEACON_CHAIN_DB_KEY: Hash256 = Hash256::ZERO; @@ -238,7 +246,7 @@ pub struct PrePayloadAttributes { /// /// The parent block number is not part of the payload attributes sent to the EL, but *is* /// sent to builders via SSE. - pub parent_block_number: u64, + pub parent_block_number: Option<u64>, /// The block root of the block being built upon (same block as fcU `headBlockHash`). pub parent_beacon_block_root: Hash256, } @@ -413,14 +421,21 @@ pub struct BeaconChain<T: BeaconChainTypes> { /// Maintains a record of which validators have been seen to create `SignedContributionAndProofs` /// in recent epochs. pub(crate) observed_sync_aggregators: RwLock<ObservedSyncAggregators<T::EthSpec>>, + /// Maintains a record of which validators have sent payload attestation messages + /// in recent slots. + pub(crate) observed_payload_attesters: RwLock<ObservedPayloadAttesters<T::EthSpec>>, /// Maintains a record of which validators have proposed blocks for each slot. pub observed_block_producers: RwLock<ObservedBlockProducers<T::EthSpec>>, /// Maintains a record of blob sidecars seen over the gossip network. - pub observed_blob_sidecars: RwLock<ObservedDataSidecars<BlobSidecar<T::EthSpec>>>, + pub observed_blob_sidecars: RwLock<ObservedDataSidecars<BlobSidecar<T::EthSpec>, T::EthSpec>>, /// Maintains a record of column sidecars seen over the gossip network. - pub observed_column_sidecars: RwLock<ObservedDataSidecars<DataColumnSidecar<T::EthSpec>>>, + pub observed_column_sidecars: + RwLock<ObservedDataSidecars<DataColumnSidecar<T::EthSpec>, T::EthSpec>>, /// Maintains a record of slashable message seen over the gossip network or RPC. pub observed_slashable: RwLock<ObservedSlashable<T::EthSpec>>, + /// Cache of pending execution payload envelopes for local block building. + /// Envelopes are stored here during block production and eventually published. + pub pending_payload_envelopes: RwLock<PendingPayloadEnvelopes<T::EthSpec>>, /// Maintains a record of which validators have submitted voluntary exits. pub observed_voluntary_exits: Mutex<ObservedOperations<SignedVoluntaryExit, T::EthSpec>>, /// Maintains a record of which validators we've seen proposer slashings for. @@ -457,16 +472,20 @@ pub struct BeaconChain<T: BeaconChainTypes> { pub beacon_proposer_cache: Arc<Mutex<BeaconProposerCache>>, /// Caches a map of `validator_index -> validator_pubkey`. pub(crate) validator_pubkey_cache: RwLock<ValidatorPubkeyCache<T>>, - /// A cache used when producing attestations. - pub(crate) attester_cache: Arc<AttesterCache>, /// A cache used when producing attestations whilst the head block is still being imported. pub early_attester_cache: EarlyAttesterCache<T::EthSpec>, /// A cache used to store verified/equivocating inclusion lists. pub inclusion_list_cache: RwLock<InclusionListCache<T::EthSpec>>, /// A cache used to keep track of various block timings. pub block_times_cache: Arc<RwLock<BlockTimesCache>>, + /// A cache used to keep track of various envelope timings. + pub envelope_times_cache: Arc<RwLock<EnvelopeTimesCache>>, /// A cache used to track pre-finalization block roots for quick rejection. pub pre_finalization_block_cache: PreFinalizationBlockCache, + /// A cache used to store gossip verified payload bids. + pub gossip_verified_payload_bid_cache: GossipVerifiedPayloadBidCache<T>, + /// A cache used to store gossip verified proposer preferences. + pub gossip_verified_proposer_preferences_cache: GossipVerifiedProposerPreferenceCache, /// A cache used to produce light_client server messages pub light_client_server_cache: LightClientServerCache<T>, /// Sender to signal the light_client server to produce new updates @@ -547,6 +566,9 @@ impl FinalizationAndCanonicity { } } +type ProcessedPartialColumnStatus<E> = + Option<(AvailabilityProcessingStatus, PartialMergeResult<E>)>; + impl<T: BeaconChainTypes> BeaconChain<T> { /// Checks if a block is finalized. /// The finalization check is done with the block slot. The block root is used to verify that @@ -665,7 +687,18 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .custody_context() .as_ref() .into(); - debug!(?custody_context, "Persisting custody context to store"); + + // Pattern match to avoid accidentally missing fields and to ignore deprecated fields. + let CustodyContextSsz { + validator_custody_at_head, + epoch_validator_custody_requirements, + persisted_is_supernode: _, + } = &custody_context; + debug!( + validator_custody_at_head, + ?epoch_validator_custody_requirements, + "Persisting custody context to store" + ); persist_custody_context::<T::EthSpec, T::HotStore, T::ColdStore>( self.store.clone(), @@ -1127,6 +1160,21 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .map_or_else(|| self.get_blobs(block_root), Ok) } + #[cfg(not(test))] + #[allow(clippy::type_complexity)] + pub fn get_payload_envelopes( + self: &Arc<Self>, + block_roots: Vec<Hash256>, + request_source: EnvelopeRequestSource, + ) -> impl Stream< + Item = ( + Hash256, + Arc<Result<Option<Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>>, Error>>, + ), + > { + launch_payload_envelope_stream(self.clone(), block_roots, request_source) + } + pub fn get_data_columns_checking_all_caches( &self, block_root: Hash256, @@ -1138,13 +1186,18 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .or_else(|| self.early_attester_cache.get_data_columns(block_root)); if let Some(mut all_cached_columns) = all_cached_columns_opt { - all_cached_columns.retain(|col| indices.contains(&col.index)); + all_cached_columns.retain(|col| indices.contains(col.index())); Ok(all_cached_columns) - } else { + } else if let Some(block) = self.get_blinded_block(&block_root)? { indices .iter() - .filter_map(|index| self.get_data_column(&block_root, index).transpose()) + .filter_map(|index| { + self.get_data_column(&block_root, index, block.fork_name_unchecked()) + .transpose() + }) .collect::<Result<_, _>>() + } else { + Ok(vec![]) } } @@ -1229,8 +1282,11 @@ impl<T: BeaconChainTypes> BeaconChain<T> { pub fn get_data_columns( &self, block_root: &Hash256, + fork_name: ForkName, ) -> Result<Option<DataColumnSidecarList<T::EthSpec>>, Error> { - self.store.get_data_columns(block_root).map_err(Error::from) + self.store + .get_data_columns(block_root, fork_name) + .map_err(Error::from) } /// Returns the blobs at the given root, if any. @@ -1251,7 +1307,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { }; if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { - if let Some(columns) = self.store.get_data_columns(block_root)? { + let fork_name = self.spec.fork_name_at_epoch(block.epoch()); + if let Some(columns) = self.store.get_data_columns(block_root, fork_name)? { let num_required_columns = T::EthSpec::number_of_columns() / 2; let reconstruction_possible = columns.len() >= num_required_columns; if reconstruction_possible { @@ -1267,7 +1324,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { Ok(None) } } else { - self.get_blobs(block_root).map(|b| b.blobs()) + Ok(self.get_blobs(block_root)?.blobs()) } } @@ -1279,8 +1336,11 @@ impl<T: BeaconChainTypes> BeaconChain<T> { &self, block_root: &Hash256, column_index: &ColumnIndex, + fork_name: ForkName, ) -> Result<Option<Arc<DataColumnSidecar<T::EthSpec>>>, Error> { - Ok(self.store.get_data_column(block_root, column_index)?) + Ok(self + .store + .get_data_column(block_root, column_index, fork_name)?) } pub fn get_blinded_block( @@ -1290,6 +1350,13 @@ impl<T: BeaconChainTypes> BeaconChain<T> { Ok(self.store.get_blinded_block(block_root)?) } + pub fn get_payload_envelope( + &self, + block_root: &Hash256, + ) -> Result<Option<SignedExecutionPayloadEnvelope<T::EthSpec>>, Error> { + Ok(self.store.get_payload_envelope(block_root)?) + } + /// Return the status of a block as it progresses through the various caches of the beacon /// chain. Used by sync to learn the status of a block and prevent repeated downloads / /// processing attempts. @@ -1424,7 +1491,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .proto_array() .heads_descended_from_finalization::<T::EthSpec>(fork_choice.finalized_checkpoint()) .iter() - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) .collect() } @@ -1652,7 +1719,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let validator_index = *validator_index as usize; committee_cache.get_attestation_duties(validator_index) }) - .collect(); + .collect::<Result<Vec<_>, _>>()?; Ok((duties, dependent_root)) }, @@ -1709,6 +1776,46 @@ impl<T: BeaconChainTypes> BeaconChain<T> { Ok((duties, dependent_root)) } + /// Get PTC duties for validators at a given epoch. + /// + /// TODO(gloas): per-validator `get_ptc_assignment` makes this O(N * slots_per_epoch * PTCSize). + /// A future ptc cache (or a single-pass `ptc_window` walk) can drop this to + /// O(slots_per_epoch * PTCSize + N). + pub fn compute_ptc_duties( + &self, + state: &BeaconState<T::EthSpec>, + epoch: Epoch, + validator_indices: &[u64], + dependent_block_root: Hash256, + ) -> Result<(Vec<Option<PtcDuty>>, Hash256), Error> { + // The ptc_window only covers previous, current, and next epochs. + let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), epoch) + .map_err(Error::IncorrectStateForAttestation)?; + + let dependent_root = + state.attester_shuffling_decision_root(dependent_block_root, relative_epoch)?; + + let pubkey_cache = self.validator_pubkey_cache.read(); + + let duties = validator_indices + .iter() + .map(|&validator_index| -> Result<Option<PtcDuty>, Error> { + let Some(&pubkey) = pubkey_cache.get_pubkey_bytes(validator_index as usize) else { + return Ok(None); + }; + let slot_opt = + state.get_ptc_assignment(validator_index as usize, epoch, &self.spec)?; + Ok(slot_opt.map(|slot| PtcDuty { + validator_index, + slot, + pubkey, + })) + }) + .collect::<Result<Vec<_>, _>>()?; + + Ok((duties, dependent_root)) + } + pub fn get_aggregated_attestation( &self, attestation: AttestationRef<T::EthSpec>, @@ -1899,6 +2006,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// ## Errors /// /// May return an error if the `request_slot` is too far behind the head state. + #[instrument(name = "lh_produce_unaggregated_attestation", skip_all, fields(%request_slot, %request_index), level = "debug")] pub fn produce_unaggregated_attestation( &self, request_slot: Slot, @@ -1942,19 +2050,18 @@ impl<T: BeaconChainTypes> BeaconChain<T> { * the head-lock is not desirable. */ - let head_state_slot; let beacon_block_root; let beacon_state_root; let target; + let is_same_slot_attestation; let current_epoch_attesting_info: Option<(Checkpoint, usize)>; - let attester_cache_key; let head_timer = metrics::start_timer(&metrics::ATTESTATION_PRODUCTION_HEAD_SCRAPE_SECONDS); + let head_span = debug_span!("attestation_production_head_scrape").entered(); // The following braces are to prevent the `cached_head` Arc from being held for longer than // required. It also helps reduce the diff for a very large PR (#3244). { let head = self.head_snapshot(); let head_state = &head.beacon_state; - head_state_slot = head_state.slot(); // There is no value in producing an attestation to a block that is pre-finalization and // it is likely to cause expensive and pointless reads to the freezer database. Exit @@ -1987,11 +2094,20 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // When attesting to the head slot or later, always use the head of the chain. beacon_block_root = head.beacon_block_root; beacon_state_root = head.beacon_state_root(); + is_same_slot_attestation = request_slot == head.beacon_block.slot(); } else { // Permit attesting to slots *prior* to the current head. This is desirable when // the VC and BN are out-of-sync due to time issues or overloading. beacon_block_root = *head_state.get_block_root(request_slot)?; beacon_state_root = *head_state.get_state_root(request_slot)?; + + // Fetch the previous block root. If the previous block root equals + // the block root being attested to, the `request_slot` is a skipped slot + // and this is not a same slot attestation. + let prior_slot_root = head_state + .get_block_root(request_slot.saturating_sub(1u64)) + .ok(); + is_same_slot_attestation = prior_slot_root != Some(&beacon_block_root); }; let target_slot = request_epoch.start_slot(T::EthSpec::slots_per_epoch()); @@ -2022,12 +2138,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // to determine the justified checkpoint and committee length. None }; - - // Determine the key for `self.attester_cache`, in case it is required later in this - // routine. - attester_cache_key = - AttesterCacheKey::new(request_epoch, head_state, beacon_block_root)?; } + drop(head_span); drop(head_timer); // Only attest to a block if it is fully verified (i.e. not optimistic or invalid). @@ -2050,47 +2162,55 @@ impl<T: BeaconChainTypes> BeaconChain<T> { * Phase 2/2: * * If the justified checkpoint and committee length from the head are suitable for this - * attestation, use them. If not, try the attester cache. If the cache misses, load a state - * from disk and prime the cache with it. + * attestation, use them. If not, use the database, which will hit the state cache. */ - - let cache_timer = - metrics::start_timer(&metrics::ATTESTATION_PRODUCTION_CACHE_INTERACTION_SECONDS); let (justified_checkpoint, committee_len) = if let Some((justified_checkpoint, committee_len)) = current_epoch_attesting_info { // The head state is in the same epoch as the attestation, so there is no more // required information. (justified_checkpoint, committee_len) - } else if let Some(cached_values) = self.attester_cache.get::<T::EthSpec>( - &attester_cache_key, - request_slot, - request_index, - &self.spec, - )? { - // The suitable values were already cached. Return them. - cached_values } else { - debug!( - ?beacon_block_root, - %head_state_slot, - %request_slot, - "Attester cache miss" - ); + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root + let (advanced_state_root, mut state) = self + .store + .get_advanced_hot_state(beacon_block_root, request_slot, beacon_state_root)? + .ok_or(Error::MissingBeaconState(beacon_state_root))?; + if state.current_epoch() < request_epoch { + partial_state_advance( + &mut state, + Some(advanced_state_root), + request_epoch.start_slot(T::EthSpec::slots_per_epoch()), + &self.spec, + ) + .map_err(Error::StateAdvanceError)?; - // Neither the head state, nor the attester cache was able to produce the required - // information to attest in this epoch. So, load a `BeaconState` from disk and use - // it to fulfil the request (and prime the cache to avoid this next time). - let _cache_build_timer = - metrics::start_timer(&metrics::ATTESTATION_PRODUCTION_CACHE_PRIME_SECONDS); - self.attester_cache.load_and_cache_state( - beacon_state_root, - attester_cache_key, - request_slot, - request_index, - self, - )? + state.build_committee_cache(RelativeEpoch::Current, &self.spec)?; + } + + ( + state.current_justified_checkpoint(), + state + .get_beacon_committee(request_slot, request_index)? + .committee + .len(), + ) }; - drop(cache_timer); + + // For gloas the attestation data index indicates payload presence: + // `payload_present=false` for same-slot attestations or when payload not received. + // `payload_present=true` when attesting to a prior slot whose payload has been received. + let payload_present = if self + .spec + .fork_name_at_slot::<T::EthSpec>(request_slot) + .gloas_enabled() + && !is_same_slot_attestation + { + self.canonical_head + .block_has_canonical_payload(&beacon_block_root, &self.spec)? + } else { + false + }; Ok(Attestation::<T::EthSpec>::empty_for_signing( request_index, @@ -2099,6 +2219,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { beacon_block_root, justified_checkpoint, target, + payload_present, &self.spec, )?) } @@ -2197,6 +2318,50 @@ impl<T: BeaconChainTypes> BeaconChain<T> { Ok(Some(inclusion_list)) } + + /// Produce a `PayloadAttestationData` for a PTC validator to sign. + /// + /// This is used by PTC (Payload Timeliness Committee) validators to attest to the + /// presence/absence of an execution payload and blobs for a given slot. + pub fn produce_payload_attestation_data( + &self, + request_slot: Slot, + ) -> Result<PayloadAttestationData, Error> { + let _timer = metrics::start_timer(&metrics::PAYLOAD_ATTESTATION_PRODUCTION_SECONDS); + + // Payload attestations are only valid for the current slot + let current_slot = self.slot()?; + if request_slot != current_slot { + return Err(Error::InvalidSlot(request_slot)); + } + + // Check if we've seen a block for this slot from the canonical head + let head = self.head_snapshot(); + if head.beacon_block.slot() != request_slot { + return Err(Error::NoBlockForSlot(request_slot)); + } + + let beacon_block_root = head.beacon_block_root; + + // TODO(gloas) do we want to use a dedicated envelope cache instead? + // Maybe the new gloas DA cache? (Or should the gloas DA cache use + // the envelopes_times_cache internally?) + let payload_present = self + .envelope_times_cache + .read() + .cache + .contains_key(&beacon_block_root); + + // TODO(EIP-7732): Check blob data availability. For now, default to true. + let blob_data_available = true; + + Ok(PayloadAttestationData { + beacon_block_root, + slot: head.beacon_block.slot(), + payload_present, + blob_data_available, + }) + } /// Performs the same validation as `Self::verify_unaggregated_attestation_for_gossip`, but for /// multiple attestations using batch BLS verification. Batch verification can provide @@ -2295,6 +2460,33 @@ impl<T: BeaconChainTypes> BeaconChain<T> { }) } + pub fn apply_payload_attestation_to_fork_choice( + &self, + indexed_payload_attestation: &IndexedPayloadAttestation<T::EthSpec>, + ptc: &PTC<T::EthSpec>, + ) -> Result<(), Error> { + self.canonical_head + .fork_choice_write_lock() + .on_payload_attestation( + self.slot()?, + indexed_payload_attestation, + AttestationFromBlock::False, + &ptc.0, + ) + .map_err(Into::into) + } + + /// Add a verified payload attestation message to the operation pool for block inclusion. + pub fn add_payload_attestation_to_pool( + &self, + verified: &VerifiedPayloadAttestationMessage<T>, + ) -> Result<(), Error> { + self.op_pool + .insert_payload_attestation_message(verified.payload_attestation_message().clone()) + .map_err(Error::OpPoolError)?; + Ok(()) + } + /// Accepts some `SyncCommitteeMessage` from the network and attempts to verify it, returning `Ok(_)` if /// it is valid to be (re)broadcast on the gossip network. pub fn verify_sync_committee_message_for_gossip( @@ -2359,6 +2551,59 @@ impl<T: BeaconChainTypes> BeaconChain<T> { }) } + pub fn verify_partial_data_column_header_for_gossip( + &self, + block_root: Hash256, + data_column_header: PartialDataColumnHeader<T::EthSpec>, + ) -> Result<GossipVerifiedPartialDataColumnHeader<T::EthSpec>, GossipPartialDataColumnError> + { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_REQUESTS); + let _timer = metrics::start_timer( + &metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_GOSSIP_VERIFICATION_TIMES, + ); + let Some(assembler) = self.data_availability_checker.partial_assembler() else { + return Err(GossipPartialDataColumnError::PartialColumnsDisabled); + }; + if let Some(cached_header) = assembler.get_header(&block_root) { + return if *cached_header == data_column_header { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_DUPES); + Ok(GossipVerifiedPartialDataColumnHeader::new_from_cached( + cached_header, + )) + } else { + Err(GossipPartialDataColumnError::HeaderMismatches) + }; + } + + GossipVerifiedPartialDataColumnHeader::new(block_root, data_column_header, self).inspect( + |_| { + metrics::inc_counter( + &metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_SUCCESSES, + ); + }, + ) + } + + #[instrument(skip_all, level = "trace")] + pub fn verify_partial_data_column_sidecar_for_gossip( + self: &Arc<Self>, + data_column_sidecar: Box<PartialDataColumn<T::EthSpec>>, + seen_timestamp: Duration, + ) -> PartialColumnVerificationResult<T::EthSpec> { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_REQUESTS); + let _timer = + metrics::start_timer(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_GOSSIP_VERIFICATION_TIMES); + let ret = validate_partial_data_column_sidecar_for_gossip( + data_column_sidecar, + self, + seen_timestamp, + ); + if matches!(ret, PartialColumnVerificationResult::Ok { .. }) { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_SUCCESSES); + } + ret + } + #[instrument(skip_all, level = "trace")] pub fn verify_blob_sidecar_for_gossip( self: &Arc<Self>, @@ -2426,6 +2671,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { self.slot()?, verified.indexed_attestation().to_ref(), AttestationFromBlock::False, + &self.spec, ) .map_err(Into::into) } @@ -2889,9 +3135,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// or already-known). /// /// This method is potentially long-running and should not run on the core executor. + #[instrument(skip_all, level = "debug")] pub fn filter_chain_segment( self: &Arc<Self>, - chain_segment: Vec<RpcBlock<T::EthSpec>>, + chain_segment: Vec<RangeSyncBlock<T::EthSpec>>, ) -> Result<Vec<HashBlockTuple<T::EthSpec>>, Box<ChainSegmentResult>> { // This function will never import any blocks. let imported_blocks = vec![]; @@ -3000,7 +3247,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// `Self::process_block`. pub async fn process_chain_segment( self: &Arc<Self>, - chain_segment: Vec<RpcBlock<T::EthSpec>>, + chain_segment: Vec<RangeSyncBlock<T::EthSpec>>, notify_execution_layer: NotifyExecutionLayer, ) -> ChainSegmentResult { for block in chain_segment.iter() { @@ -3016,12 +3263,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // Filter uninteresting blocks from the chain segment in a blocking task. let chain = self.clone(); - let filter_chain_segment = debug_span!("filter_chain_segment"); let filtered_chain_segment_future = self.spawn_blocking_handle( - move || { - let _guard = filter_chain_segment.enter(); - chain.filter_chain_segment(chain_segment) - }, + move || chain.filter_chain_segment(chain_segment), "filter_chain_segment", ); let mut filtered_chain_segment = match filtered_chain_segment_future.await { @@ -3052,12 +3295,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { std::mem::swap(&mut blocks, &mut filtered_chain_segment); let chain = self.clone(); - let current_span = Span::current(); let signature_verification_future = self.spawn_blocking_handle( - move || { - let _guard = current_span.enter(); - signature_verify_chain_segment(blocks, &chain) - }, + move || signature_verify_chain_segment(blocks, &chain), "signature_verify_chain_segment", ); @@ -3147,12 +3386,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { block: Arc<SignedBeaconBlock<T::EthSpec>>, ) -> Result<GossipVerifiedBlock<T>, BlockError> { let chain = self.clone(); - let span = Span::current(); self.task_executor .clone() .spawn_blocking_handle( move || { - let _guard = span.enter(); let slot = block.slot(); let graffiti_string = block.message().body().graffiti().as_utf8_lossy(); @@ -3219,6 +3456,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// Cache the data columns in the processing cache, process it, then evict it from the cache if it was /// imported or errors. + /// Only accepts full columns. Partials are handled via PartialDataColumnAssembler. #[instrument(skip_all, level = "debug")] pub async fn process_gossip_data_columns( self: &Arc<Self>, @@ -3260,6 +3498,93 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .await } + /// Process a gossip-verified partial data column by attempting to merge it in the assembler. + /// Returns the merge result which indicates if a column was completed. + #[instrument(skip_all, level = "debug")] + pub async fn process_gossip_partial_data_column( + self: &Arc<Self>, + verified_partial: KzgVerifiedPartialDataColumn<T::EthSpec>, + verified_header: GossipVerifiedPartialDataColumnHeader<T::EthSpec>, + slot: Slot, + ) -> Result<ProcessedPartialColumnStatus<T::EthSpec>, BlockError> { + let block_root = verified_partial.block_root(); + let partial = verified_partial.as_data_column(); + let index_str = partial.index.to_string(); + metrics::inc_counter_vec_by( + &metrics::BEACON_PARTIAL_MESSAGE_CELLS_RECEIVED_TOTAL, + &[index_str.as_str()], + partial.sidecar.column.len() as u64, + ); + + // Check if we have custody of this column + let sampling_columns = + self.sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); + let verified_partial = if sampling_columns.contains(&partial.index) { + KzgVerifiedCustodyPartialDataColumn::from_asserted_custody(verified_partial) + } else { + return Ok(None); + }; + + // If this block has already been imported to forkchoice it must have been available + if self + .canonical_head + .fork_choice_read_lock() + .contains_block(&block_root) + { + return Err(BlockError::DuplicateFullyImported(block_root)); + } + + let Some(assembler) = self.data_availability_checker.partial_assembler() else { + // Partial messages are apparently not activated + return Ok(None); + }; + + // Merge the partial into the assembler + let merge_result = assembler + .merge_partials( + block_root, + vec![verified_partial], + verified_header.into_header(), + ) + .ok_or_else(|| BlockError::InternalError("No assembly found for block".to_string()))?; + + metrics::inc_counter_vec_by( + &metrics::BEACON_PARTIAL_MESSAGE_USEFUL_CELLS_TOTAL, + &[index_str.as_str()], + merge_result.added_cells as u64, + ); + + let availability = if !merge_result.full_columns.is_empty() { + metrics::inc_counter_vec_by( + &metrics::BEACON_PARTIAL_MESSAGE_COLUMN_COMPLETIONS_TOTAL, + &[index_str.as_str()], + merge_result.full_columns.len() as u64, + ); + + self.emit_sse_data_column_sidecar_events( + &block_root, + merge_result + .full_columns + .iter() + .map(|column| column.as_data_column()), + ); + + let availability = self + .data_availability_checker + .put_kzg_verified_custody_data_columns( + block_root, + merge_result.full_columns.clone(), + )?; + + self.process_availability(slot, availability, || Ok(())) + .await? + } else { + AvailabilityProcessingStatus::MissingComponents(slot, block_root) + }; + + Ok(Some((availability, merge_result))) + } + /// Cache the blobs in the processing cache, process it, then evict it from the cache if it was /// imported or errors. #[instrument(skip_all, level = "debug")] @@ -3369,7 +3694,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .cached_data_column_indexes(block_root) .unwrap_or_default(); let new_data_columns = - data_columns_iter.filter(|b| !imported_data_columns.contains(&b.index)); + data_columns_iter.filter(|b| !imported_data_columns.contains(b.index())); for data_column in new_data_columns { event_handler.register(EventKind::DataColumnSidecar( @@ -3381,6 +3706,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// Cache the columns in the processing cache, process it, then evict it from the cache if it was /// imported or errors. + // TODO(gloas) we need a separate code path for gloas. See TODO's below. pub async fn process_rpc_custody_columns( self: &Arc<Self>, custody_columns: DataColumnSidecarList<T::EthSpec>, @@ -3398,6 +3724,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // If this block has already been imported to forkchoice it must have been available, so // we don't need to process its columns again. + // TODO(gloas) the block will be available in fork choice for gloas. This does not indicate availability + // anymore. if self .canonical_head .fork_choice_read_lock() @@ -3409,7 +3737,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // Reject RPC columns referencing unknown parents. Otherwise we allow potentially invalid data // into the da_checker, where invalid = descendant of invalid blocks. // Note: custody_columns should have at least one item and all items have the same parent root. - if let Some(parent_root) = custody_columns.iter().map(|c| c.block_parent_root()).next() + // TODO(gloas) ensure this check is no longer relevant post gloas + if let Some(parent_root) = custody_columns + .iter() + .filter_map(|c| match c.as_ref() { + DataColumnSidecar::Fulu(column) => Some(column.block_parent_root()), + _ => None, + }) + .next() && !self .canonical_head .fork_choice_read_lock() @@ -3450,11 +3785,9 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let data_availability_checker = self.data_availability_checker.clone(); - let current_span = Span::current(); let result = self .task_executor .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { - let _guard = current_span.enter(); data_availability_checker.reconstruct_data_columns(&block_root) }) .await @@ -3530,11 +3863,19 @@ impl<T: BeaconChainTypes> BeaconChain<T> { ); } - self.data_availability_checker.put_pre_execution_block( - block_root, - unverified_block.block_cloned(), - block_source, - )?; + // Gloas blocks dont need to be inserted into the DA cache + // they are always available. + if !unverified_block + .block() + .fork_name_unchecked() + .gloas_enabled() + { + self.data_availability_checker.put_pre_execution_block( + block_root, + unverified_block.block_cloned(), + block_source, + )?; + } // Start the Prometheus timer. let _full_timer = metrics::start_timer(&metrics::BLOCK_PROCESSING_TIMES); @@ -3657,28 +3998,6 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .map_err(BeaconChainError::TokioJoin)? .ok_or(BeaconChainError::RuntimeShutdown)??; - // Log the PoS pandas if a merge transition just occurred. - if payload_verification_outcome.is_valid_merge_transition_block { - info!("{}", POS_PANDA_BANNER); - info!(slot = %block.slot(), "Proof of Stake Activated"); - info!( - terminal_pow_block_hash = ?block - .message() - .execution_payload()? - .parent_hash() - .into_root(), - ); - info!( - merge_transition_block_root = ?block.message().tree_hash_root(), - ); - info!( - merge_transition_execution_hash = ?block - .message() - .execution_payload()? - .block_hash() - .into_root(), - ); - } Ok(ExecutedBlock::new( block, import_data, @@ -3721,6 +4040,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// Checks if the provided data column can make any cached blocks available, and imports immediately /// if so, otherwise caches the data column in the data availability checker. + /// Check gossip data columns for availability and import. Only accepts full columns. + /// Partials are handled separately via PartialDataColumnAssembler. async fn check_gossip_data_columns_availability_and_import( self: &Arc<Self>, slot: Slot, @@ -3729,8 +4050,12 @@ impl<T: BeaconChainTypes> BeaconChain<T> { publish_fn: impl FnOnce() -> Result<(), BlockError>, ) -> Result<AvailabilityProcessingStatus, BlockError> { if let Some(slasher) = self.slasher.as_ref() { - for data_colum in &data_columns { - slasher.accept_block_header(data_colum.signed_block_header()); + for data_column in &data_columns { + // TODO(gloas) different gossip checks in gloas + // https://github.com/ethereum/consensus-specs/blob/81458afc6aad6985c533785c8d2860d87a993241/specs/gloas/p2p-interface.md?plain=1#L385 + if let DataColumnSidecar::Fulu(c) = data_column.as_data_column() { + slasher.accept_block_header(c.signed_block_header.clone()); + } } } @@ -3808,9 +4133,15 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .put_kzg_verified_blobs(block_root, blobs)? } EngineGetBlobsOutput::CustodyColumns(data_columns) => { + // TODO(gloas) verify that this check is no longer relevant for gloas self.check_data_column_sidecar_header_signature_and_slashability( block_root, - data_columns.iter().map(|c| c.as_data_column()), + data_columns + .iter() + .filter_map(|c| match c.as_data_column() { + DataColumnSidecar::Fulu(column) => Some(column), + _ => None, + }), )?; self.data_availability_checker .put_kzg_verified_custody_data_columns(block_root, data_columns)? @@ -3829,9 +4160,13 @@ impl<T: BeaconChainTypes> BeaconChain<T> { block_root: Hash256, custody_columns: DataColumnSidecarList<T::EthSpec>, ) -> Result<AvailabilityProcessingStatus, BlockError> { + // TODO(gloas) ensure that this check is no longer relevant post gloas self.check_data_column_sidecar_header_signature_and_slashability( block_root, - custody_columns.iter().map(|c| c.as_ref()), + custody_columns.iter().filter_map(|c| match c.as_ref() { + DataColumnSidecar::Fulu(fulu) => Some(fulu), + _ => None, + }), )?; // This slot value is purely informative for the consumers of @@ -3849,7 +4184,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { fn check_data_column_sidecar_header_signature_and_slashability<'a>( self: &Arc<Self>, block_root: Hash256, - custody_columns: impl IntoIterator<Item = &'a DataColumnSidecar<T::EthSpec>>, + custody_columns: impl IntoIterator<Item = &'a DataColumnSidecarFulu<T::EthSpec>>, ) -> Result<(), BlockError> { let mut slashable_cache = self.observed_slashable.write(); // Process all unique block headers - previous logic assumed all headers were identical and @@ -3857,13 +4192,13 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // from RPC. for header in custody_columns .into_iter() - .map(|c| c.signed_block_header.clone()) + .map(|c| &c.signed_block_header) .unique() { // Return an error if *any* header signature is invalid, we do not want to import this // list of blobs into the DA checker. However, we will process any valid headers prior // to the first invalid header in the slashable cache & slasher. - verify_header_signature::<T, BlockError>(self, &header)?; + verify_header_signature::<T, BlockError>(self, header)?; slashable_cache .observe_slashable( @@ -3873,7 +4208,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; if let Some(slasher) = self.slasher.as_ref() { - slasher.accept_block_header(header); + slasher.accept_block_header(header.clone()); } } Ok(()) @@ -3919,7 +4254,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { consensus_context, } = import_data; - // Record the time at which this block's blobs became available. + // Record the time at which this block's blobs/data columns became available. if let Some(blobs_available) = block.blobs_available_timestamp() { self.block_times_cache.write().set_time_blob_observed( block_root, @@ -3928,16 +4263,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { ); } - // TODO(das) record custody column available timestamp - let block_root = { - // Capture the current span before moving into the blocking task - let current_span = tracing::Span::current(); let chain = self.clone(); self.spawn_blocking_handle( move || { - // Enter the captured span in the blocking thread - let _guard = current_span.enter(); chain.import_block( block, block_root, @@ -4013,17 +4342,15 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } }; - // Apply the state to the attester cache, only if it is from the previous epoch or later. - // - // In a perfect scenario there should be no need to add previous-epoch states to the cache. - // However, latency between the VC and the BN might cause the VC to produce attestations at - // a previous slot. - if state.current_epoch().saturating_add(1_u64) >= current_epoch { - let _attester_span = debug_span!("attester_cache_update").entered(); - self.attester_cache - .maybe_cache_state(&state, block_root, &self.spec) - .map_err(BeaconChainError::from)?; - } + // Read the cached head prior to taking the fork choice lock to avoid potential deadlocks. + let cached_head = self.canonical_head.cached_head(); + let old_head_slot = cached_head.head_slot(); + + // Compute the expected proposer for `current_slot` on the canonical chain. This is used by + // `on_block` to gate proposer boost on the block's proposer matching the canonical proposer + // (per spec `update_proposer_boost_root` added in v1.7.0-alpha.5). + let canonical_head_proposer_index = + self.canonical_head_proposer_index(current_slot, &cached_head)?; // Take an upgradable read lock on fork choice so we can check if this block has already // been imported. We don't want to repeat work importing a block that is already imported. @@ -4056,6 +4383,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { block_delay, &state, payload_verification_status, + canonical_head_proposer_index, &self.spec, ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; @@ -4078,14 +4406,16 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PROCESSING_FORK_CHOICE); match fork_choice.get_head(current_slot, &self.spec) { // This block became the head, add it to the early attester cache. - Ok(new_head_root) if new_head_root == block_root => { + Ok((new_head_root, _)) if new_head_root == block_root => { if let Some(proto_block) = fork_choice.get_block(&block_root) { + let new_head_is_optimistic = + proto_block.execution_status.is_optimistic_or_invalid(); + if let Err(e) = self.early_attester_cache.add_head_block( block_root, &signed_block, proto_block, &state, - &self.spec, ) { warn!( error = ?e, @@ -4100,6 +4430,50 @@ impl<T: BeaconChainTypes> BeaconChain<T> { attestable_timestamp, ) } + + // Register a server-sent-event for a new head. + if let Some(event_handler) = self + .event_handler + .as_ref() + .filter(|handler| handler.has_head_subscribers()) + { + let head_slot = state.slot(); + let state_root = block.state_root(); + let is_epoch_transition = state.current_epoch() + > old_head_slot.epoch(T::EthSpec::slots_per_epoch()); + + let dependent_root = state.attester_shuffling_decision_root( + self.genesis_block_root, + RelativeEpoch::Next, + ); + let prev_dependent_root = state.attester_shuffling_decision_root( + self.genesis_block_root, + RelativeEpoch::Current, + ); + + match (dependent_root, prev_dependent_root) { + ( + Ok(current_duty_dependent_root), + Ok(previous_duty_dependent_root), + ) => { + event_handler.register(EventKind::Head(SseHead { + slot: head_slot, + block: block_root, + state: state_root, + current_duty_dependent_root, + previous_duty_dependent_root, + epoch_transition: is_epoch_transition, + execution_optimistic: new_head_is_optimistic, + })); + } + (Err(e), _) | (_, Err(e)) => { + warn!( + error = ?e, + "Unable to find dependent roots, cannot register head event" + ); + } + } + } } else { warn!(?block_root, "Early attester block missing"); } @@ -4147,23 +4521,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // See https://github.com/sigp/lighthouse/issues/2028 let (_, signed_block, block_data) = signed_block.deconstruct(); - match self.get_blobs_or_columns_store_op(block_root, signed_block.slot(), block_data) { - Ok(Some(blobs_or_columns_store_op)) => { - ops.push(blobs_or_columns_store_op); - } - Ok(None) => {} - Err(e) => { - error!( - msg = "Restoring fork choice from disk", - error = &e, - ?block_root, - "Failed to store data columns into the database" - ); - return Err(self - .handle_import_block_db_write_error(fork_choice) - .err() - .unwrap_or(BlockError::InternalError(e))); - } + if let Some(blobs_or_columns_store_op) = + self.get_blobs_or_columns_store_op(block_root, signed_block.slot(), block_data) + { + ops.push(blobs_or_columns_store_op); } let block = signed_block.message(); @@ -4193,7 +4554,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // We're declaring the block "imported" at this point, since fork choice and the DB know // about it. - let block_time_imported = timestamp_now(); + let block_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); // compute state proofs for light client updates before inserting the state into the // snapshot cache. @@ -4262,7 +4623,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } /// Check block's consistentency with any configured weak subjectivity checkpoint. - fn check_block_against_weak_subjectivity_checkpoint( + pub(crate) fn check_block_against_weak_subjectivity_checkpoint( &self, block: BeaconBlockRef<T::EthSpec>, block_root: Hash256, @@ -4609,55 +4970,6 @@ impl<T: BeaconChainTypes> BeaconChain<T> { Ok(()) } - /// If configured, wait for the fork choice run at the start of the slot to complete. - #[instrument(level = "debug", skip_all)] - fn wait_for_fork_choice_before_block_production( - self: &Arc<Self>, - slot: Slot, - ) -> Result<(), BlockProductionError> { - if let Some(rx) = &self.fork_choice_signal_rx { - let current_slot = self - .slot() - .map_err(|_| BlockProductionError::UnableToReadSlot)?; - - let timeout = Duration::from_millis(self.config.fork_choice_before_proposal_timeout_ms); - - if slot == current_slot || slot == current_slot + 1 { - match rx.wait_for_fork_choice(slot, timeout) { - ForkChoiceWaitResult::Success(fc_slot) => { - debug!( - %slot, - fork_choice_slot = %fc_slot, - "Fork choice successfully updated before block production" - ); - } - ForkChoiceWaitResult::Behind(fc_slot) => { - warn!( - fork_choice_slot = %fc_slot, - %slot, - message = "this block may be orphaned", - "Fork choice notifier out of sync with block production" - ); - } - ForkChoiceWaitResult::TimeOut => { - warn!( - message = "this block may be orphaned", - "Timed out waiting for fork choice before proposal" - ); - } - } - } else { - error!( - %slot, - %current_slot, - message = "check clock sync, this block may be orphaned", - "Producing block at incorrect slot" - ); - } - } - Ok(()) - } - pub async fn produce_block_with_verification( self: &Arc<Self>, randao_reveal: Signature, @@ -4673,20 +4985,19 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // // Load the parent state from disk. let chain = self.clone(); - let span = Span::current(); - let (state, state_root_opt) = self + let block_production_state = self .task_executor .spawn_blocking_handle( - move || { - let _guard = - debug_span!(parent: span, "load_state_for_block_production").entered(); - chain.load_state_for_block_production(slot) - }, + move || chain.load_state_for_block_production(slot), "load_state_for_block_production", ) .ok_or(BlockProductionError::ShuttingDown)? .await .map_err(BlockProductionError::TokioJoin)??; + let (state, state_root_opt) = ( + block_production_state.state, + block_production_state.state_root, + ); // Part 2/2 (async, with some blocking components) // @@ -4704,164 +5015,6 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .await } - /// Load a beacon state from the database for block production. This is a long-running process - /// that should not be performed in an `async` context. - fn load_state_for_block_production( - self: &Arc<Self>, - slot: Slot, - ) -> Result<(BeaconState<T::EthSpec>, Option<Hash256>), BlockProductionError> { - let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_FORK_CHOICE_TIMES); - self.wait_for_fork_choice_before_block_production(slot)?; - drop(fork_choice_timer); - - let state_load_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_LOAD_TIMES); - - // Atomically read some values from the head whilst avoiding holding cached head `Arc` any - // longer than necessary. - let (head_slot, head_block_root, head_state_root) = { - let head = self.canonical_head.cached_head(); - ( - head.head_slot(), - head.head_block_root(), - head.head_state_root(), - ) - }; - let (state, state_root_opt) = if head_slot < slot { - // Attempt an aggressive re-org if configured and the conditions are right. - if let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) - { - info!( - %slot, - head_to_reorg = %head_block_root, - "Proposing block to re-org current head" - ); - (re_org_state, Some(re_org_state_root)) - } else { - // Fetch the head state advanced through to `slot`, which should be present in the - // state cache thanks to the state advance timer. - let (state_root, state) = self - .store - .get_advanced_hot_state(head_block_root, slot, head_state_root) - .map_err(BlockProductionError::FailedToLoadState)? - .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; - (state, Some(state_root)) - } - } else { - warn!( - message = "this block is more likely to be orphaned", - %slot, - "Producing block that conflicts with head" - ); - let state = self - .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) - .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - - (state, None) - }; - - drop(state_load_timer); - - Ok((state, state_root_opt)) - } - - /// Fetch the beacon state to use for producing a block if a 1-slot proposer re-org is viable. - /// - /// This function will return `None` if proposer re-orgs are disabled. - #[instrument(skip_all, level = "debug")] - fn get_state_for_re_org( - &self, - slot: Slot, - head_slot: Slot, - canonical_head: Hash256, - ) -> Option<(BeaconState<T::EthSpec>, Hash256)> { - let re_org_head_threshold = self.config.re_org_head_threshold?; - let re_org_parent_threshold = self.config.re_org_parent_threshold?; - - if self.spec.proposer_score_boost.is_none() { - warn!( - reason = "this network does not have proposer boost enabled", - "Ignoring proposer re-org configuration" - ); - return None; - } - - let slot_delay = self - .slot_clock - .seconds_from_current_slot_start() - .or_else(|| { - warn!(error = "unable to read slot clock", "Not attempting re-org"); - None - })?; - - // Attempt a proposer re-org if: - // - // 1. It seems we have time to propagate and still receive the proposer boost. - // 2. The current head block was seen late. - // 3. The `get_proposer_head` conditions from fork choice pass. - let proposing_on_time = slot_delay < self.config.re_org_cutoff(self.spec.seconds_per_slot); - if !proposing_on_time { - debug!(reason = "not proposing on time", "Not attempting re-org"); - return None; - } - - let head_late = self.block_observed_after_attestation_deadline(canonical_head, head_slot); - if !head_late { - debug!(reason = "head not late", "Not attempting re-org"); - return None; - } - - // Is the current head weak and appropriate for re-orging? - let proposer_head_timer = - metrics::start_timer(&metrics::BLOCK_PRODUCTION_GET_PROPOSER_HEAD_TIMES); - let proposer_head = self - .canonical_head - .fork_choice_read_lock() - .get_proposer_head( - slot, - canonical_head, - re_org_head_threshold, - re_org_parent_threshold, - &self.config.re_org_disallowed_offsets, - self.config.re_org_max_epochs_since_finalization, - ) - .map_err(|e| match e { - ProposerHeadError::DoNotReOrg(reason) => { - debug!( - %reason, - "Not attempting re-org" - ); - } - ProposerHeadError::Error(e) => { - warn!( - error = ?e, - "Not attempting re-org" - ); - } - }) - .ok()?; - drop(proposer_head_timer); - let re_org_parent_block = proposer_head.parent_node.root; - - let (state_root, state) = self - .store - .get_advanced_hot_state_from_cache(re_org_parent_block, slot) - .or_else(|| { - warn!(reason = "no state in cache", "Not attempting re-org"); - None - })?; - - info!( - weak_head = ?canonical_head, - parent = ?re_org_parent_block, - head_weight = proposer_head.head_node.weight, - threshold_weight = proposer_head.re_org_head_weight_threshold, - "Attempting re-org due to weak head" - ); - - Some((state, state_root)) - } - /// Get the proposer index and `prev_randao` value for a proposal at slot `proposal_slot`. /// /// The `proposer_head` may be the head block of `cached_head` or its parent. An error will @@ -4944,15 +5097,25 @@ impl<T: BeaconChainTypes> BeaconChain<T> { return Ok(None); }; - // Get the `prev_randao` and parent block number. - let head_block_number = cached_head.head_block_number()?; - let (prev_randao, parent_block_number) = if proposer_head == head_parent_block_root { - ( - cached_head.parent_random()?, - head_block_number.saturating_sub(1), - ) + // TODO(gloas) not sure what to do here see this issue + // https://github.com/sigp/lighthouse/issues/8817 + let (prev_randao, parent_block_number) = if self + .spec + .fork_name_at_slot::<T::EthSpec>(proposal_slot) + .gloas_enabled() + { + (cached_head.head_random()?, None) } else { - (cached_head.head_random()?, head_block_number) + // Get the `prev_randao` and parent block number. + let head_block_number = cached_head.head_block_number()?; + if proposer_head == head_parent_block_root { + ( + cached_head.parent_random()?, + Some(head_block_number.saturating_sub(1)), + ) + } else { + (cached_head.head_random()?, Some(head_block_number)) + } }; Ok(Some(PrePayloadAttributes { @@ -4963,19 +5126,61 @@ impl<T: BeaconChainTypes> BeaconChain<T> { })) } + /// Compute the expected beacon proposer for `slot` on the canonical chain extending `cached_head`. + /// + /// Uses the beacon proposer cache to avoid recomputing the shuffling on every block import. + /// + /// This is used by `update_proposer_boost_root` to gate proposer boost on the block's proposer + /// matching the canonical proposer, per consensus-specs v1.7.0-alpha.5. + /// + /// This function should never error unless there is some corruption of the head state. If a + /// state advance is needed, it will be handled by the proposer cache. + pub fn canonical_head_proposer_index( + &self, + slot: Slot, + cached_head: &CachedHead<T::EthSpec>, + ) -> Result<u64, Error> { + let proposal_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let head_block_root = cached_head.head_block_root(); + let head_state = &cached_head.snapshot.beacon_state; + + let shuffling_decision_root = head_state.proposer_shuffling_decision_root_at_epoch( + proposal_epoch, + head_block_root, + &self.spec, + )?; + + self.with_proposer_cache::<_, Error>( + shuffling_decision_root, + proposal_epoch, + |proposers| { + proposers + .get_slot::<T::EthSpec>(slot) + .map(|p| p.index as u64) + }, + || Ok((cached_head.head_state_root(), head_state.clone())), + ) + } + pub fn get_expected_withdrawals( &self, forkchoice_update_params: &ForkchoiceUpdateParameters, proposal_slot: Slot, ) -> Result<Withdrawals<T::EthSpec>, Error> { let cached_head = self.canonical_head.cached_head(); + let head_block = &cached_head.snapshot.beacon_block; + let head_block_root = cached_head.head_block_root(); let head_state = &cached_head.snapshot.beacon_state; let parent_block_root = forkchoice_update_params.head_root; - let (unadvanced_state, unadvanced_state_root) = - if cached_head.head_block_root() == parent_block_root { - (Cow::Borrowed(head_state), cached_head.head_state_root()) + let (unadvanced_state, unadvanced_state_root, parent_bid_block_hash) = + if parent_block_root == head_block_root { + ( + Cow::Borrowed(head_state), + cached_head.head_state_root(), + head_block.payload_bid_block_hash().ok(), + ) } else { let block = self .get_blinded_block(&parent_block_root)? @@ -4984,20 +5189,27 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .store .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? .ok_or(Error::MissingBeaconState(block.state_root()))?; - (Cow::Owned(state), state_root) + ( + Cow::Owned(state), + state_root, + block.payload_bid_block_hash().ok(), + ) }; - // Parent state epoch is the same as the proposal, we don't need to advance because the - // list of expected withdrawals can only change after an epoch advance or a - // block application. - let proposal_epoch = proposal_slot.epoch(T::EthSpec::slots_per_epoch()); - if head_state.current_epoch() == proposal_epoch { - return get_expected_withdrawals(&unadvanced_state, &self.spec) - .map(|(withdrawals, _)| withdrawals) - .map_err(Error::PrepareProposerFailed); - } + let parent_payload_status = if let Some(block_hash) = parent_bid_block_hash + && block_hash != ExecutionBlockHash::default() + && forkchoice_update_params.head_hash == Some(block_hash) + { + fork_choice::PayloadStatus::Full + } else { + fork_choice::PayloadStatus::Empty + }; // Advance the state using the partial method. + // TODO(gloas): we might want to optimise this further by using: + // - `get_advanced_hot_state` instead of the cached head + // - restoring the pre-Gloas optimisation to avoid advancing further than the epoch + // boundary debug!( %proposal_slot, ?parent_block_root, @@ -5007,11 +5219,32 @@ impl<T: BeaconChainTypes> BeaconChain<T> { partial_state_advance( &mut advanced_state, Some(unadvanced_state_root), - proposal_epoch.start_slot(T::EthSpec::slots_per_epoch()), + proposal_slot, &self.spec, )?; + + // For Gloas, when the head payload is Full, we need to apply the parent's + // execution requests to the state to get the correct withdrawals. + if parent_payload_status == fork_choice::PayloadStatus::Full { + let envelope = if parent_block_root == head_block_root { + cached_head.snapshot.execution_envelope.clone() + } else { + self.store + .get_payload_envelope(&parent_block_root)? + .map(Arc::new) + } + .ok_or(Error::MissingExecutionPayloadEnvelope(parent_block_root))?; + + apply_parent_execution_payload( + &mut advanced_state, + &envelope.message.execution_requests, + &self.spec, + ) + .map_err(Error::PrepareProposerFailed)?; + } + get_expected_withdrawals(&advanced_state, &self.spec) - .map(|(withdrawals, _)| withdrawals) + .map(Into::into) .map_err(Error::PrepareProposerFailed) } @@ -5040,6 +5273,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { }) } + // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -5074,7 +5308,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // The slot of our potential re-org block is always 1 greater than the head block because we // only attempt single-slot re-orgs. - let head_slot = info.head_node.slot; + let head_slot = info.head_node.slot(); let re_org_block_slot = head_slot + 1; let fork_choice_slot = info.current_slot; @@ -5089,7 +5323,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .and_then(|slot_start| { let now = self.slot_clock.now_duration()?; let slot_delay = now.saturating_sub(slot_start); - Some(slot_delay <= self.config.re_org_cutoff(self.spec.seconds_per_slot)) + Some(slot_delay <= self.config.re_org_cutoff(self.spec.get_slot_duration())) }) .unwrap_or(false) } else { @@ -5109,9 +5343,9 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .fork_name_at_slot::<T::EthSpec>(re_org_block_slot) .fulu_enabled() { - info.head_node.current_epoch_shuffling_id + info.head_node.current_epoch_shuffling_id() } else { - info.head_node.next_epoch_shuffling_id + info.head_node.next_epoch_shuffling_id() } .shuffling_decision_block; let proposer_index = self @@ -5137,13 +5371,15 @@ impl<T: BeaconChainTypes> BeaconChain<T> { return Err(Box::new(DoNotReOrg::NotProposing.into())); } - // If the current slot is already equal to the proposal slot (or we are in the tail end of - // the prior slot), then check the actual weight of the head against the head re-org threshold - // and the actual weight of the parent against the parent re-org threshold. + // TODO(gloas): reorg weight logic needs updating for Gloas. For now use + // total weight which is correct for pre-Gloas and conservative for post-Gloas. + let head_weight = info.head_node.weight(); + let parent_weight = info.parent_node.weight(); + let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( - info.head_node.weight < info.re_org_head_weight_threshold, - info.parent_node.weight > info.re_org_parent_weight_threshold, + head_weight < info.re_org_head_weight_threshold, + parent_weight > info.re_org_parent_weight_threshold, ) } else { (true, true) @@ -5151,7 +5387,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { if !head_weak { return Err(Box::new( DoNotReOrg::HeadNotWeak { - head_weight: info.head_node.weight, + head_weight, re_org_head_weight_threshold: info.re_org_head_weight_threshold, } .into(), @@ -5160,7 +5396,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { if !parent_strong { return Err(Box::new( DoNotReOrg::ParentNotStrong { - parent_weight: info.parent_node.weight, + parent_weight, re_org_parent_weight_threshold: info.re_org_parent_weight_threshold, } .into(), @@ -5178,9 +5414,16 @@ impl<T: BeaconChainTypes> BeaconChain<T> { return Err(Box::new(DoNotReOrg::HeadNotLate.into())); } - let parent_head_hash = info.parent_node.execution_status.block_hash(); + // TODO(gloas): V29 nodes don't carry execution_status, so this returns + // None for post-Gloas re-orgs. Need to source the EL block hash from + // the bid's block_hash instead. Re-org is disabled for Gloas for now. + let parent_head_hash = info + .parent_node + .execution_status() + .ok() + .and_then(|execution_status| execution_status.block_hash()); let forkchoice_update_params = ForkchoiceUpdateParameters { - head_root: info.parent_node.root, + head_root: info.parent_node.root(), head_hash: parent_head_hash, justified_hash: canonical_forkchoice_params.justified_hash, finalized_hash: canonical_forkchoice_params.finalized_hash, @@ -5188,7 +5431,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { debug!( canonical_head = ?head_block_root, - ?info.parent_node.root, + parent_root = ?info.parent_node.root(), slot = %fork_choice_slot, "Fork choice update overridden" ); @@ -5197,7 +5440,11 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } /// Check if the block with `block_root` was observed after the attestation deadline of `slot`. - fn block_observed_after_attestation_deadline(&self, block_root: Hash256, slot: Slot) -> bool { + pub(crate) fn block_observed_after_attestation_deadline( + &self, + block_root: Hash256, + slot: Slot, + ) -> bool { let block_delays = self.block_times_cache.read().get_block_delays( block_root, self.slot_clock @@ -5206,7 +5453,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { ); block_delays .observed - .is_some_and(|delay| delay >= self.slot_clock.unagg_attestation_production_delay()) + .is_some_and(|delay| delay >= self.spec.get_unaggregated_attestation_due()) } /// Produce a block for some `slot` upon the given `state`. @@ -5242,13 +5489,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .graffiti_calculator .get_graffiti(graffiti_settings) .await; - let span = Span::current(); let mut partial_beacon_block = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "produce_partial_beacon_block").entered(); chain.produce_partial_beacon_block( state, state_root_opt, @@ -5284,14 +5528,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { match block_contents_type { BlockProposalContentsType::Full(block_contents) => { let chain = self.clone(); - let span = Span::current(); let beacon_block_response = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block") - .entered(); chain.complete_partial_beacon_block( partial_beacon_block, Some(block_contents), @@ -5308,14 +5548,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } BlockProposalContentsType::Blinded(block_contents) => { let chain = self.clone(); - let span = Span::current(); let beacon_block_response = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block") - .entered(); chain.complete_partial_beacon_block( partial_beacon_block, Some(block_contents), @@ -5333,13 +5569,10 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } } else { let chain = self.clone(); - let span = Span::current(); let beacon_block_response = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block").entered(); chain.complete_partial_beacon_block( partial_beacon_block, None, @@ -5357,6 +5590,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } #[allow(clippy::too_many_arguments)] + #[instrument(skip_all, level = "debug")] fn produce_partial_beacon_block( self: &Arc<Self>, mut state: BeaconState<T::EthSpec>, @@ -5556,7 +5790,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { err = ?e, block_slot = %state.slot(), ?exit, - "Attempted to include an invalid proposer slashing" + "Attempted to include an invalid voluntary exit" ); }) .is_ok() @@ -5601,6 +5835,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { }) } + #[instrument(skip_all, level = "debug")] fn complete_partial_beacon_block<Payload: AbstractExecPayload<T::EthSpec>>( &self, partial_beacon_block: PartialBeaconBlock<T::EthSpec>, @@ -6019,7 +6254,11 @@ impl<T: BeaconChainTypes> BeaconChain<T> { execution_payload_value, ) } - BeaconState::Gloas(_) => return Err(BlockProductionError::GloasNotImplemented), + BeaconState::Gloas(_) => { + return Err(BlockProductionError::GloasNotImplemented( + "Attempting to produce gloas beacon block via non gloas code path".to_owned(), + )); + } }; let block = SignedBeaconBlock::from_block( @@ -6270,13 +6509,20 @@ impl<T: BeaconChainTypes> BeaconChain<T> { fcu_params.head_root, &cached_head, )?; - Ok::<_, Error>(Some((fcu_params, pre_payload_attributes))) + let head_payload_status = cached_head.head_payload_status(); + Ok::<_, Error>(Some(( + fcu_params, + pre_payload_attributes, + head_payload_status, + ))) }, "prepare_beacon_proposer_head_read", ) .await??; - let Some((forkchoice_update_params, Some(pre_payload_attributes))) = maybe_prep_data else { + let Some((forkchoice_update_params, Some(pre_payload_attributes), head_payload_status)) = + maybe_prep_data + else { // Appropriate log messages have already been logged above and in // `get_pre_payload_attributes`. return Ok(None); @@ -6298,7 +6544,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // considerable time to compute if a state load is required. let head_root = forkchoice_update_params.head_root; let payload_attributes = if let Some(payload_attributes) = execution_layer - .payload_attributes(prepare_slot, head_root) + .payload_attributes(prepare_slot, head_root, head_payload_status) .await { payload_attributes @@ -6323,6 +6569,12 @@ impl<T: BeaconChainTypes> BeaconChain<T> { None }; + let slot_number = if prepare_slot_fork.gloas_enabled() { + Some(prepare_slot.as_u64()) + } else { + None + }; + let payload_attributes = PayloadAttributes::new( self.slot_clock .start_of(prepare_slot) @@ -6332,12 +6584,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> { execution_layer.get_suggested_fee_recipient(proposer).await, withdrawals.map(Into::into), parent_beacon_block_root, + slot_number, ); execution_layer .insert_proposer( prepare_slot, head_root, + head_payload_status, proposer, payload_attributes.clone(), ) @@ -6349,6 +6603,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { %prepare_slot, validator = proposer, parent_root = ?head_root, + payload_status = ?head_payload_status, "Prepared beacon proposer" ); payload_attributes @@ -6357,13 +6612,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // Push a server-sent event (probably to a block builder or relay). if let Some(event_handler) = &self.event_handler && event_handler.has_payload_attributes_subscribers() + && let Some(parent_block_number) = pre_payload_attributes.parent_block_number { event_handler.register(EventKind::PayloadAttributes(ForkVersionedResponse { data: SseExtendedPayloadAttributes { proposal_slot: prepare_slot, proposer_index: proposer, parent_block_root: head_root, - parent_block_number: pre_payload_attributes.parent_block_number, + parent_block_number, parent_block_hash: forkchoice_update_params.head_hash.unwrap_or_default(), payload_attributes: payload_attributes.into(), }, @@ -6400,6 +6656,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { self.update_execution_engine_forkchoice( current_slot, forkchoice_update_params, + head_payload_status, OverrideForkchoiceUpdate::AlreadyApplied, ) .await?; @@ -6412,23 +6669,9 @@ impl<T: BeaconChainTypes> BeaconChain<T> { self: &Arc<Self>, current_slot: Slot, input_params: ForkchoiceUpdateParameters, + head_payload_status: fork_choice::PayloadStatus, override_forkchoice_update: OverrideForkchoiceUpdate, ) -> Result<(), Error> { - let next_slot = current_slot + 1; - - // There is no need to issue a `forkchoiceUpdated` (fcU) message unless the Bellatrix fork - // has: - // - // 1. Already happened. - // 2. Will happen in the next slot. - // - // The reason for a fcU message in the slot prior to the Bellatrix fork is in case the - // terminal difficulty has already been reached and a payload preparation message needs to - // be issued. - if self.slot_is_prior_to_bellatrix(next_slot) { - return Ok(()); - } - let execution_layer = self .execution_layer .as_ref() @@ -6476,50 +6719,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .unwrap_or_else(ExecutionBlockHash::zero), ) } else { - // The head block does not have an execution block hash. We must check to see if we - // happen to be the proposer of the transition block, in which case we still need to - // send forkchoice_updated. - if self - .spec - .fork_name_at_slot::<T::EthSpec>(next_slot) - .bellatrix_enabled() - { - // We are post-bellatrix - if let Some(payload_attributes) = execution_layer - .payload_attributes(next_slot, params.head_root) - .await - { - // We are a proposer, check for terminal_pow_block_hash - if let Some(terminal_pow_block_hash) = execution_layer - .get_terminal_pow_block_hash(&self.spec, payload_attributes.timestamp()) - .await - .map_err(Error::ForkchoiceUpdate)? - { - info!( - slot = %next_slot, - "Prepared POS transition block proposer" - ); - ( - params.head_root, - terminal_pow_block_hash, - params - .justified_hash - .unwrap_or_else(ExecutionBlockHash::zero), - params - .finalized_hash - .unwrap_or_else(ExecutionBlockHash::zero), - ) - } else { - // TTD hasn't been reached yet, no need to update the EL. - return Ok(()); - } - } else { - // We are not a proposer, no need to update the EL. - return Ok(()); - } - } else { - return Ok(()); - } + // Proposing the block for the merge is no longer supported. + return Ok(()); }; let forkchoice_updated_response = execution_layer @@ -6529,6 +6730,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { finalized_hash, current_slot, head_block_root, + head_payload_status, ) .await .map_err(Error::ExecutionForkChoiceUpdateFailed); @@ -6812,6 +7014,9 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // sync anyway). self.naive_aggregation_pool.write().prune(slot); self.block_times_cache.write().prune(slot); + self.envelope_times_cache.write().prune(slot); + self.gossip_verified_payload_bid_cache.prune(slot); + self.gossip_verified_proposer_preferences_cache.prune(slot); // Don't run heavy-weight tasks during sync. if self.best_slot() + MAX_PER_SLOT_FORK_CHOICE_DISTANCE < slot { @@ -6871,62 +7076,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> { accessor: impl Fn(&EpochBlockProposers) -> Result<V, BeaconChainError>, state_provider: impl FnOnce() -> Result<(Hash256, BeaconState<T::EthSpec>), E>, ) -> Result<V, E> { - let cache_entry = self - .beacon_proposer_cache - .lock() - .get_or_insert_key(proposal_epoch, shuffling_decision_block); - - // If the cache entry is not initialised, run the code to initialise it inside a OnceCell. - // This prevents duplication of work across multiple threads. - // - // If it is already initialised, then `get_or_try_init` will return immediately without - // executing the initialisation code at all. - let epoch_block_proposers = cache_entry.get_or_try_init(|| { - // Fetch the state on-demand if the required epoch was missing from the cache. - // If the caller wants to not compute the state they must return an error here and then - // catch it at the call site. - let (state_root, mut state) = state_provider()?; - - // Ensure the state can compute proposer duties for `epoch`. - ensure_state_can_determine_proposers_for_epoch( - &mut state, - state_root, - proposal_epoch, - &self.spec, - )?; - - // Sanity check the state. - let latest_block_root = state.get_latest_block_root(state_root); - let state_decision_block_root = state.proposer_shuffling_decision_root_at_epoch( - proposal_epoch, - latest_block_root, - &self.spec, - )?; - if state_decision_block_root != shuffling_decision_block { - return Err(Error::ProposerCacheIncorrectState { - state_decision_block_root, - requested_decision_block_root: shuffling_decision_block, - } - .into()); - } - - let proposers = state.get_beacon_proposer_indices(proposal_epoch, &self.spec)?; - - // Use fork_at_epoch rather than the state's fork, because post-Fulu we may not have - // advanced the state completely into the new epoch. - let fork = self.spec.fork_at_epoch(proposal_epoch); - - debug!( - ?shuffling_decision_block, - epoch = %proposal_epoch, - "Priming proposer shuffling cache" - ); - - Ok::<_, E>(EpochBlockProposers::new(proposal_epoch, fork, proposers)) - })?; - - // Run the accessor function on the computed epoch proposers. - accessor(epoch_block_proposers).map_err(Into::into) + crate::beacon_proposer_cache::with_proposer_cache( + &self.beacon_proposer_cache, + shuffling_decision_block, + proposal_epoch, + accessor, + state_provider, + &self.spec, + ) } /// Runs the `map_fn` with the committee cache for `shuffling_epoch` from the chain with head @@ -7064,6 +7221,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let (mut state, state_root) = if let Some((state, state_root)) = head_state_opt { (state, state_root) } else { + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (state_root, state) = self .store .get_advanced_hot_state(head_block_root, target_slot, head_block.state_root)? @@ -7133,6 +7292,9 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let mut prev_block_root = None; let mut prev_beacon_state = None; + // Collect all blocks. + let mut blocks = vec![]; + for res in self.forwards_iter_block_roots(from_slot)? { let (beacon_block_root, _) = res?; @@ -7148,16 +7310,50 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .ok_or_else(|| { Error::DBInconsistent(format!("Missing block {}", beacon_block_root)) })?; - let beacon_state_root = beacon_block.state_root(); + blocks.push((beacon_block_root, Arc::new(beacon_block))); + } + + // Collect envelopes, using the next blocks to determine if payloads are canonical + // (the parent block was full). + for (i, (block_root, block)) in blocks.iter().enumerate() { + let opt_envelope = if block.fork_name_unchecked().gloas_enabled() { + let opt_envelope = self.store.get_payload_envelope(block_root)?.map(Arc::new); + + if let Some((_, next_block)) = blocks.get(i + 1) { + let block_hash = block.payload_bid_block_hash()?; + if next_block.is_parent_block_full(block_hash) { + let envelope = opt_envelope.ok_or_else(|| { + Error::DBInconsistent(format!("Missing envelope {block_root:?}")) + })?; + Some(envelope) + } else { + None + } + } else { + // Last block in the sequence: use canonical head to determine + // whether the payload is canonical. + let head = self.canonical_head.cached_head(); + assert_eq!(head.head_block_root(), *block_root); + let payload_received = + head.head_payload_status() == fork_choice::PayloadStatus::Full; + if payload_received { + let envelope = opt_envelope.ok_or_else(|| { + Error::DBInconsistent(format!("Missing envelope {block_root:?}")) + })?; + Some(envelope) + } else { + None + } + } + } else { + None + }; + let state_root = block.state_root(); - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. let mut beacon_state = self .store - .get_state(&beacon_state_root, Some(beacon_block.slot()), true)? - .ok_or_else(|| { - Error::DBInconsistent(format!("Missing state {:?}", beacon_state_root)) - })?; + .get_state(&state_root, Some(block.slot()), true)? + .ok_or_else(|| Error::DBInconsistent(format!("Missing state {:?}", state_root)))?; // This beacon state might come from the freezer DB, which means it could have pending // updates or lots of untethered memory. We rebase it on the previous state in order to @@ -7170,12 +7366,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> { prev_beacon_state = Some(beacon_state.clone()); let snapshot = BeaconSnapshot { - beacon_block: Arc::new(beacon_block), - beacon_block_root, + beacon_block: block.clone(), + execution_envelope: opt_envelope, + beacon_block_root: *block_root, beacon_state, }; dump.push(snapshot); } + Ok(dump) } @@ -7644,16 +7842,16 @@ impl<T: BeaconChainTypes> BeaconChain<T> { block_root: Hash256, block_slot: Slot, block_data: AvailableBlockData<T::EthSpec>, - ) -> Result<Option<StoreOp<'_, T::EthSpec>>, String> { + ) -> Option<StoreOp<'_, T::EthSpec>> { match block_data { - AvailableBlockData::NoData => Ok(None), + AvailableBlockData::NoData => None, AvailableBlockData::Blobs(blobs) => { debug!( %block_root, count = blobs.len(), "Writing blobs to store" ); - Ok(Some(StoreOp::PutBlobs(block_root, blobs))) + Some(StoreOp::PutBlobs(block_root, blobs)) } AvailableBlockData::DataColumns(mut data_columns) => { let columns_to_custody = self.custody_columns_for_epoch(Some( @@ -7662,20 +7860,24 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // Supernodes need to persist all sampled custody columns if columns_to_custody.len() != self.spec.number_of_custody_groups as usize { data_columns - .retain(|data_column| columns_to_custody.contains(&data_column.index)); + .retain(|data_column| columns_to_custody.contains(data_column.index())); } debug!( %block_root, count = data_columns.len(), "Writing data columns to store" ); - Ok(Some(StoreOp::PutDataColumns(block_root, data_columns))) + Some(StoreOp::PutDataColumns(block_root, data_columns)) } } } /// Retrieves block roots (in ascending slot order) within some slot range from fork choice. - pub fn block_roots_from_fork_choice(&self, start_slot: u64, count: u64) -> Vec<Hash256> { + pub fn block_roots_from_fork_choice( + &self, + start_slot: u64, + count: u64, + ) -> Vec<(Hash256, Slot)> { let head_block_root = self.canonical_head.cached_head().head_block_root(); let fork_choice_read_lock = self.canonical_head.fork_choice_read_lock(); let block_roots_iter = fork_choice_read_lock @@ -7686,7 +7888,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { for (root, slot) in block_roots_iter { if slot < end_slot && slot >= start_slot { - roots.push(root); + roots.push((root, slot)); } if slot < start_slot { break; diff --git a/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs b/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs index 7705fced43..e5832dd88f 100644 --- a/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs +++ b/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs @@ -236,38 +236,6 @@ where } } - /// Restore `Self` from a previously-generated `PersistedForkChoiceStore`. - /// - /// DEPRECATED. Can be deleted once migrations no longer require it. - pub fn from_persisted_v17( - persisted: PersistedForkChoiceStoreV17, - justified_state_root: Hash256, - unrealized_justified_state_root: Hash256, - store: Arc<HotColdDB<E, Hot, Cold>>, - ) -> Result<Self, Error> { - let justified_balances = - JustifiedBalances::from_effective_balances(persisted.justified_balances)?; - - Ok(Self { - store, - balances_cache: <_>::default(), - time: persisted.time, - finalized_checkpoint: persisted.finalized_checkpoint, - justified_checkpoint: persisted.justified_checkpoint, - justified_balances, - justified_state_root, - unrealized_justified_checkpoint: persisted.unrealized_justified_checkpoint, - unrealized_justified_state_root, - unrealized_finalized_checkpoint: persisted.unrealized_finalized_checkpoint, - proposer_boost_root: persisted.proposer_boost_root, - equivocating_indices: persisted.equivocating_indices, - // TODO(eip7805) should persist these values? - inclusion_list_equivocators: <_>::default(), - unsatisfied_inclusion_list_blocks: <_>::default(), - _phantom: PhantomData, - }) - } - /// Restore `Self` from a previously-generated `PersistedForkChoiceStore`. pub fn from_persisted( persisted: PersistedForkChoiceStore, @@ -439,45 +407,15 @@ where pub type PersistedForkChoiceStore = PersistedForkChoiceStoreV28; /// A container which allows persisting the `BeaconForkChoiceStore` to the on-disk database. -#[superstruct( - variants(V17, V28), - variant_attributes(derive(Encode, Decode)), - no_enum -)] +#[superstruct(variants(V28), variant_attributes(derive(Encode, Decode)), no_enum)] pub struct PersistedForkChoiceStore { - /// The balances cache was removed from disk storage in schema V28. - #[superstruct(only(V17))] - pub balances_cache: BalancesCacheV8, pub time: Slot, pub finalized_checkpoint: Checkpoint, pub justified_checkpoint: Checkpoint, - /// The justified balances were removed from disk storage in schema V28. - #[superstruct(only(V17))] - pub justified_balances: Vec<u64>, - /// The justified state root is stored so that it can be used to load the justified balances. - #[superstruct(only(V28))] pub justified_state_root: Hash256, pub unrealized_justified_checkpoint: Checkpoint, - #[superstruct(only(V28))] pub unrealized_justified_state_root: Hash256, pub unrealized_finalized_checkpoint: Checkpoint, pub proposer_boost_root: Hash256, pub equivocating_indices: BTreeSet<u64>, } - -// Convert V28 to V17 by adding balances and removing justified state roots. -impl From<(PersistedForkChoiceStoreV28, JustifiedBalances)> for PersistedForkChoiceStoreV17 { - fn from((v28, balances): (PersistedForkChoiceStoreV28, JustifiedBalances)) -> Self { - Self { - balances_cache: Default::default(), - time: v28.time, - finalized_checkpoint: v28.finalized_checkpoint, - justified_checkpoint: v28.justified_checkpoint, - justified_balances: balances.effective_balances, - unrealized_justified_checkpoint: v28.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: v28.unrealized_finalized_checkpoint, - proposer_boost_root: v28.proposer_boost_root, - equivocating_indices: v28.equivocating_indices, - } - } -} diff --git a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs index a923d657a8..b258d7471f 100644 --- a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs +++ b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs @@ -12,14 +12,15 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; use fork_choice::ExecutionStatus; use lru::LruCache; use once_cell::sync::OnceCell; +use parking_lot::Mutex; use safe_arith::SafeArith; use smallvec::SmallVec; use state_processing::state_advance::partial_state_advance; use std::num::NonZeroUsize; use std::sync::Arc; -use tracing::instrument; +use tracing::{debug, instrument}; use typenum::Unsigned; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; use types::{BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, Fork, Hash256, Slot}; /// The number of sets of proposer indices that should be cached. @@ -164,6 +165,82 @@ impl BeaconProposerCache { } } +/// Access the proposer cache, computing and caching the proposers if necessary. +/// +/// This is a free function that operates on references to the cache and spec, decoupled from +/// `BeaconChain`. The `accessor` is called with the cached `EpochBlockProposers` for the given +/// `(proposal_epoch, shuffling_decision_block)` key. If the cache entry is missing, the +/// `state_provider` closure is called to produce a state which is then used to compute and +/// cache the proposers. +pub fn with_proposer_cache<Spec, V, Err>( + beacon_proposer_cache: &Mutex<BeaconProposerCache>, + shuffling_decision_block: Hash256, + proposal_epoch: Epoch, + accessor: impl Fn(&EpochBlockProposers) -> Result<V, BeaconChainError>, + state_provider: impl FnOnce() -> Result<(Hash256, BeaconState<Spec>), Err>, + spec: &ChainSpec, +) -> Result<V, Err> +where + Spec: EthSpec, + Err: From<BeaconChainError> + From<BeaconStateError>, +{ + let cache_entry = beacon_proposer_cache + .lock() + .get_or_insert_key(proposal_epoch, shuffling_decision_block); + + // If the cache entry is not initialised, run the code to initialise it inside a OnceCell. + // This prevents duplication of work across multiple threads. + // + // If it is already initialised, then `get_or_try_init` will return immediately without + // executing the initialisation code at all. + let epoch_block_proposers = cache_entry.get_or_try_init(|| { + // Fetch the state on-demand if the required epoch was missing from the cache. + // If the caller wants to not compute the state they must return an error here and then + // catch it at the call site. + let (state_root, mut state) = state_provider()?; + + // Ensure the state can compute proposer duties for `epoch`. + ensure_state_can_determine_proposers_for_epoch( + &mut state, + state_root, + proposal_epoch, + spec, + )?; + + // Sanity check the state. + let latest_block_root = state.get_latest_block_root(state_root); + let state_decision_block_root = state.proposer_shuffling_decision_root_at_epoch( + proposal_epoch, + latest_block_root, + spec, + )?; + if state_decision_block_root != shuffling_decision_block { + return Err(BeaconChainError::ProposerCacheIncorrectState { + state_decision_block_root, + requested_decision_block_root: shuffling_decision_block, + } + .into()); + } + + let proposers = state.get_beacon_proposer_indices(proposal_epoch, spec)?; + + // Use fork_at_epoch rather than the state's fork, because post-Fulu we may not have + // advanced the state completely into the new epoch. + let fork = spec.fork_at_epoch(proposal_epoch); + + debug!( + ?shuffling_decision_block, + epoch = %proposal_epoch, + "Priming proposer shuffling cache" + ); + + Ok::<_, Err>(EpochBlockProposers::new(proposal_epoch, fork, proposers)) + })?; + + // Run the accessor function on the computed epoch proposers. + accessor(epoch_block_proposers).map_err(Into::into) +} + /// Compute the proposer duties using the head state without cache. /// /// Return: diff --git a/beacon_node/beacon_chain/src/beacon_snapshot.rs b/beacon_node/beacon_chain/src/beacon_snapshot.rs index e9fde48ac6..996a964386 100644 --- a/beacon_node/beacon_chain/src/beacon_snapshot.rs +++ b/beacon_node/beacon_chain/src/beacon_snapshot.rs @@ -2,7 +2,7 @@ use serde::Serialize; use std::sync::Arc; use types::{ AbstractExecPayload, BeaconState, EthSpec, FullPayload, Hash256, SignedBeaconBlock, - SignedBlindedBeaconBlock, + SignedBlindedBeaconBlock, SignedExecutionPayloadEnvelope, }; /// Represents some block and its associated state. Generally, this will be used for tracking the @@ -10,6 +10,7 @@ use types::{ #[derive(Clone, Serialize, PartialEq, Debug)] pub struct BeaconSnapshot<E: EthSpec, Payload: AbstractExecPayload<E> = FullPayload<E>> { pub beacon_block: Arc<SignedBeaconBlock<E, Payload>>, + pub execution_envelope: Option<Arc<SignedExecutionPayloadEnvelope<E>>>, pub beacon_block_root: Hash256, pub beacon_state: BeaconState<E>, } @@ -31,11 +32,13 @@ impl<E: EthSpec, Payload: AbstractExecPayload<E>> BeaconSnapshot<E, Payload> { /// Create a new checkpoint. pub fn new( beacon_block: Arc<SignedBeaconBlock<E, Payload>>, + execution_envelope: Option<Arc<SignedExecutionPayloadEnvelope<E>>>, beacon_block_root: Hash256, beacon_state: BeaconState<E>, ) -> Self { Self { beacon_block, + execution_envelope, beacon_block_root, beacon_state, } @@ -54,10 +57,12 @@ impl<E: EthSpec, Payload: AbstractExecPayload<E>> BeaconSnapshot<E, Payload> { pub fn update( &mut self, beacon_block: Arc<SignedBeaconBlock<E, Payload>>, + execution_envelope: Option<Arc<SignedExecutionPayloadEnvelope<E>>>, beacon_block_root: Hash256, beacon_state: BeaconState<E>, ) { self.beacon_block = beacon_block; + self.execution_envelope = execution_envelope; self.beacon_block_root = beacon_block_root; self.beacon_state = beacon_state; } diff --git a/beacon_node/beacon_chain/src/bellatrix_readiness.rs b/beacon_node/beacon_chain/src/bellatrix_readiness.rs index 412870354b..34d9795b84 100644 --- a/beacon_node/beacon_chain/src/bellatrix_readiness.rs +++ b/beacon_node/beacon_chain/src/bellatrix_readiness.rs @@ -1,126 +1,9 @@ -//! Provides tools for checking if a node is ready for the Bellatrix upgrade and following merge -//! transition. +//! Provides tools for checking genesis execution payload consistency. use crate::{BeaconChain, BeaconChainError as Error, BeaconChainTypes}; use execution_layer::BlockByNumberQuery; -use serde::{Deserialize, Serialize, Serializer}; -use std::fmt; -use std::fmt::Write; use types::*; -/// The time before the Bellatrix fork when we will start issuing warnings about preparation. -pub const SECONDS_IN_A_WEEK: u64 = 604800; -pub const BELLATRIX_READINESS_PREPARATION_SECONDS: u64 = SECONDS_IN_A_WEEK * 2; - -#[derive(Default, Debug, Serialize, Deserialize)] -pub struct MergeConfig { - #[serde(serialize_with = "serialize_uint256")] - pub terminal_total_difficulty: Option<Uint256>, - #[serde(skip_serializing_if = "Option::is_none")] - pub terminal_block_hash: Option<ExecutionBlockHash>, - #[serde(skip_serializing_if = "Option::is_none")] - pub terminal_block_hash_epoch: Option<Epoch>, -} - -impl fmt::Display for MergeConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.terminal_block_hash.is_none() - && self.terminal_block_hash_epoch.is_none() - && self.terminal_total_difficulty.is_none() - { - return write!( - f, - "Merge terminal difficulty parameters not configured, check your config" - ); - } - let mut display_string = String::new(); - if let Some(terminal_total_difficulty) = self.terminal_total_difficulty { - write!( - display_string, - "terminal_total_difficulty: {},", - terminal_total_difficulty - )?; - } - if let Some(terminal_block_hash) = self.terminal_block_hash { - write!( - display_string, - "terminal_block_hash: {},", - terminal_block_hash - )?; - } - if let Some(terminal_block_hash_epoch) = self.terminal_block_hash_epoch { - write!( - display_string, - "terminal_block_hash_epoch: {},", - terminal_block_hash_epoch - )?; - } - write!(f, "{}", display_string.trim_end_matches(','))?; - Ok(()) - } -} -impl MergeConfig { - /// Instantiate `self` from the values in a `ChainSpec`. - pub fn from_chainspec(spec: &ChainSpec) -> Self { - let mut params = MergeConfig::default(); - if spec.terminal_total_difficulty != Uint256::MAX { - params.terminal_total_difficulty = Some(spec.terminal_total_difficulty); - } - if spec.terminal_block_hash != ExecutionBlockHash::zero() { - params.terminal_block_hash = Some(spec.terminal_block_hash); - } - if spec.terminal_block_hash_activation_epoch != Epoch::max_value() { - params.terminal_block_hash_epoch = Some(spec.terminal_block_hash_activation_epoch); - } - params - } -} - -/// Indicates if a node is ready for the Bellatrix upgrade and subsequent merge transition. -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[serde(tag = "type")] -pub enum BellatrixReadiness { - /// The node is ready, as far as we can tell. - Ready { - config: MergeConfig, - #[serde(serialize_with = "serialize_uint256")] - current_difficulty: Option<Uint256>, - }, - /// The EL can be reached and has the correct configuration, however it's not yet synced. - NotSynced, - /// The user has not configured this node to use an execution endpoint. - NoExecutionEndpoint, -} - -impl fmt::Display for BellatrixReadiness { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BellatrixReadiness::Ready { - config: params, - current_difficulty, - } => { - write!( - f, - "This node appears ready for Bellatrix \ - Params: {}, current_difficulty: {:?}", - params, current_difficulty - ) - } - BellatrixReadiness::NotSynced => write!( - f, - "The execution endpoint is connected and configured, \ - however it is not yet synced" - ), - BellatrixReadiness::NoExecutionEndpoint => write!( - f, - "The --execution-endpoint flag is not specified, this is a \ - requirement for Bellatrix" - ), - } - } -} - pub enum GenesisExecutionPayloadStatus { Correct(ExecutionBlockHash), BlockHashMismatch { @@ -141,47 +24,6 @@ pub enum GenesisExecutionPayloadStatus { } impl<T: BeaconChainTypes> BeaconChain<T> { - /// Returns `true` if user has an EL configured, or if the Bellatrix fork has occurred or will - /// occur within `BELLATRIX_READINESS_PREPARATION_SECONDS`. - pub fn is_time_to_prepare_for_bellatrix(&self, current_slot: Slot) -> bool { - if let Some(bellatrix_epoch) = self.spec.bellatrix_fork_epoch { - let bellatrix_slot = bellatrix_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let bellatrix_readiness_preparation_slots = - BELLATRIX_READINESS_PREPARATION_SECONDS / self.spec.seconds_per_slot; - - if self.execution_layer.is_some() { - // The user has already configured an execution layer, start checking for readiness - // right away. - true - } else { - // Return `true` if Bellatrix has happened or is within the preparation time. - current_slot + bellatrix_readiness_preparation_slots > bellatrix_slot - } - } else { - // The Bellatrix fork epoch has not been defined yet, no need to prepare. - false - } - } - - /// Attempts to connect to the EL and confirm that it is ready for Bellatrix. - pub async fn check_bellatrix_readiness(&self, current_slot: Slot) -> BellatrixReadiness { - if let Some(el) = self.execution_layer.as_ref() { - if !el.is_synced_for_notifier(current_slot).await { - // The EL is not synced. - return BellatrixReadiness::NotSynced; - } - let params = MergeConfig::from_chainspec(&self.spec); - let current_difficulty = el.get_current_difficulty().await.ok().flatten(); - BellatrixReadiness::Ready { - config: params, - current_difficulty, - } - } else { - // There is no EL configured. - BellatrixReadiness::NoExecutionEndpoint - } - } - /// Check that the execution payload embedded in the genesis state matches the EL's genesis /// block. pub async fn check_genesis_execution_payload_is_correct( @@ -223,14 +65,3 @@ impl<T: BeaconChainTypes> BeaconChain<T> { Ok(GenesisExecutionPayloadStatus::Correct(exec_block_hash)) } } - -/// Utility function to serialize a Uint256 as a decimal string. -fn serialize_uint256<S>(val: &Option<Uint256>, s: S) -> Result<S::Ok, S::Error> -where - S: Serializer, -{ - match val { - Some(v) => v.to_string().serialize(s), - None => s.serialize_none(), - } -} diff --git a/beacon_node/beacon_chain/src/blob_verification.rs b/beacon_node/beacon_chain/src/blob_verification.rs index 874673b52e..e557a24369 100644 --- a/beacon_node/beacon_chain/src/blob_verification.rs +++ b/beacon_node/beacon_chain/src/blob_verification.rs @@ -8,14 +8,16 @@ use crate::block_verification::{ BlockSlashInfo, get_validator_pubkey_cache, process_block_slash_info, }; use crate::kzg_utils::{validate_blob, validate_blobs}; -use crate::observed_data_sidecars::{ObservationStrategy, Observe}; +use crate::observed_data_sidecars::{ + Error as ObservedDataSidecarsError, ObservationStrategy, Observe, +}; use crate::{BeaconChainError, metrics}; use kzg::{Error as KzgError, Kzg, KzgCommitment}; use ssz_derive::{Decode, Encode}; use std::time::Duration; use tracing::{debug, instrument}; use tree_hash::TreeHash; -use types::blob_sidecar::BlobIdentifier; +use types::data::BlobIdentifier; use types::{ BeaconStateError, BlobSidecar, Epoch, EthSpec, Hash256, SignedBeaconBlockHeader, Slot, }; @@ -451,8 +453,9 @@ pub fn validate_blob_sidecar_for_gossip<T: BeaconChainTypes, O: ObservationStrat if chain .observed_blob_sidecars .read() - .proposer_is_known(&blob_sidecar) + .observation_key_is_known(&blob_sidecar) .map_err(|e| GossipBlobError::BeaconChainError(Box::new(e.into())))? + .is_some() { return Err(GossipBlobError::RepeatBlob { proposer: blob_proposer_index, @@ -505,6 +508,8 @@ pub fn validate_blob_sidecar_for_gossip<T: BeaconChainTypes, O: ObservationStrat index = %blob_index, "Proposer shuffling cache miss for blob verification" ); + // Blob verification is only relevant pre-Fulu and pre-Gloas, so `Pending` payload + // status is sufficient. chain .store .get_advanced_hot_state(block_parent_root, blob_slot, parent_block.state_root) @@ -593,7 +598,10 @@ pub fn observe_gossip_blob<T: BeaconChainTypes>( .observed_blob_sidecars .write() .observe_sidecar(blob_sidecar) - .map_err(|e| GossipBlobError::BeaconChainError(Box::new(e.into())))? + .map_err(|e: ObservedDataSidecarsError| { + GossipBlobError::BeaconChainError(Box::new(e.into())) + })? + .is_some() { return Err(GossipBlobError::RepeatBlob { proposer: blob_sidecar.block_proposer_index(), diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs new file mode 100644 index 0000000000..d82c4a4873 --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -0,0 +1,1370 @@ +use std::collections::{HashMap, HashSet}; +use std::marker::PhantomData; +use std::sync::Arc; + +use bls::{PublicKeyBytes, Signature}; +use execution_layer::{ + BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, +}; +use fork_choice::PayloadStatus; +use operation_pool::CompactAttestationRef; +use ssz::Encode; +use state_processing::common::{get_attesting_indices_from_state, get_indexed_payload_attestation}; +use state_processing::envelope_processing::verify_execution_payload_envelope; +use state_processing::epoch_cache::initialize_epoch_cache; +use state_processing::per_block_processing::is_valid_indexed_payload_attestation; +use state_processing::per_block_processing::{ + apply_parent_execution_payload, compute_timestamp_at_slot, get_expected_withdrawals, + verify_attestation_for_block_inclusion, +}; +use state_processing::{ + BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, VerifySignatures, +}; +use state_processing::{VerifyOperation, state_advance::complete_state_advance}; +use task_executor::JoinHandle; +use tracing::{Instrument, debug, debug_span, error, instrument, trace, warn}; +use tree_hash::TreeHash; +use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; +use types::{ + Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, + BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, + ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, + Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, +}; + +use crate::pending_payload_envelopes::PendingEnvelopeData; +use crate::{ + BeaconChain, BeaconChainError, BeaconChainTypes, BlockProductionError, + ProduceBlockVerification, block_production::BlockProductionState, + graffiti_calculator::GraffitiSettings, metrics, +}; + +pub const BID_VALUE_SELF_BUILD: u64 = 0; +pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; + +type ConsensusBlockValue = u64; +type BlockProductionResult<E> = (BeaconBlock<E>, BeaconState<E>, ConsensusBlockValue); + +pub type PreparePayloadResult<E> = Result<BlockProposalContentsGloas<E>, BlockProductionError>; +pub type PreparePayloadHandle<E> = JoinHandle<Option<PreparePayloadResult<E>>>; + +pub struct PartialBeaconBlock<E: EthSpec> { + slot: Slot, + proposer_index: u64, + parent_root: Hash256, + randao_reveal: Signature, + eth1_data: Eth1Data, + graffiti: Graffiti, + proposer_slashings: Vec<ProposerSlashing>, + attester_slashings: Vec<AttesterSlashingElectra<E>>, + attestations: Vec<AttestationElectra<E>>, + payload_attestations: Vec<PayloadAttestation<E>>, + deposits: Vec<Deposit>, + voluntary_exits: Vec<SignedVoluntaryExit>, + sync_aggregate: SyncAggregate<E>, + bls_to_execution_changes: Vec<SignedBlsToExecutionChange>, +} + +/// Data needed to construct an ExecutionPayloadEnvelope. +/// The envelope requires the beacon_block_root which can only be computed after the block exists. +pub struct ExecutionPayloadData<E: types::EthSpec> { + pub payload: ExecutionPayloadGloas<E>, + pub execution_requests: ExecutionRequests<E>, + pub builder_index: BuilderIndex, + pub slot: Slot, + pub blobs_and_proofs: (types::BlobsList<E>, types::KzgProofs<E>), +} + +/// The result of a local payload build, used to decide whether to include a builder bid +/// from the gossip cache or fall back to self-build. +pub struct LocalBuildResult<E: EthSpec> { + pub payload_data: ExecutionPayloadData<E>, + /// EL block value (in wei) of the locally-built payload. + pub payload_value: types::Uint256, + /// `true` if the EL signaled `engine_getPayload`'s `shouldOverrideBuilder` flag. + pub should_override_builder: bool, +} + +impl<T: BeaconChainTypes> BeaconChain<T> { + pub async fn produce_block_with_verification_gloas( + self: &Arc<Self>, + randao_reveal: Signature, + slot: Slot, + graffiti_settings: GraffitiSettings, + verification: ProduceBlockVerification, + builder_boost_factor: Option<u64>, + ) -> Result<BlockProductionResult<T::EthSpec>, BlockProductionError> { + metrics::inc_counter(&metrics::BLOCK_PRODUCTION_REQUESTS); + let _complete_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_TIMES); + // Part 1/2 (blocking) + // + // Load the parent state from disk. + let chain = self.clone(); + let block_production_state = self + .task_executor + .spawn_blocking_handle( + move || chain.load_state_for_block_production(slot), + "load_state_for_block_production", + ) + .ok_or(BlockProductionError::ShuttingDown)? + .await + .map_err(BlockProductionError::TokioJoin)??; + let BlockProductionState { + state, + state_root: state_root_opt, + parent_payload_status, + parent_envelope, + } = block_production_state; + + // Part 2/2 (async, with some blocking components) + // + // Produce the block upon the state + self.produce_block_on_state_gloas( + state, + state_root_opt, + parent_payload_status, + parent_envelope, + slot, + randao_reveal, + graffiti_settings, + verification, + builder_boost_factor, + ) + .await + } + + #[instrument(level = "debug", skip_all)] + #[allow(clippy::too_many_arguments)] + pub async fn produce_block_on_state_gloas( + self: &Arc<Self>, + state: BeaconState<T::EthSpec>, + state_root_opt: Option<Hash256>, + parent_payload_status: PayloadStatus, + parent_envelope: Option<Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>>, + produce_at_slot: Slot, + randao_reveal: Signature, + graffiti_settings: GraffitiSettings, + verification: ProduceBlockVerification, + builder_boost_factor: Option<u64>, + ) -> Result<BlockProductionResult<T::EthSpec>, BlockProductionError> { + // Extract the parent's execution requests from the envelope (if parent was full). + let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { + parent_envelope + .as_ref() + .map(|env| env.message.execution_requests.clone()) + .ok_or(BlockProductionError::MissingParentExecutionPayload)? + } else { + ExecutionRequests::default() + }; + + // Part 1/3 (blocking) + // + // Perform the state advance and block-packing functions. + let chain = self.clone(); + let graffiti = self + .graffiti_calculator + .get_graffiti(graffiti_settings) + .await; + let parent_execution_requests_ref = parent_execution_requests.clone(); + let (partial_beacon_block, state) = self + .task_executor + .spawn_blocking_handle( + move || { + chain.produce_partial_beacon_block_gloas( + state, + state_root_opt, + produce_at_slot, + randao_reveal, + graffiti, + &parent_execution_requests_ref, + ) + }, + "produce_partial_beacon_block_gloas", + ) + .ok_or(BlockProductionError::ShuttingDown)? + .await + .map_err(BlockProductionError::TokioJoin)??; + + // Part 2/3 (async) + // + // Produce a local execution payload bid, then select between it and any cached + // gossip-verified builder bid using `builder_boost_factor`. + // TODO(gloas) build out trustless/trusted bid paths. + let (local_signed_bid, state, local_build) = self + .clone() + .produce_execution_payload_bid( + state, + parent_payload_status, + parent_envelope, + produce_at_slot, + BID_VALUE_SELF_BUILD, + BUILDER_INDEX_SELF_BUILD, + ) + .await?; + + let (execution_payload_bid, payload_data) = + self.select_payload_bid(local_signed_bid, local_build, builder_boost_factor); + + // Part 3/3 (blocking) + // + // Complete the block with the execution payload bid. + let chain = self.clone(); + self.task_executor + .spawn_blocking_handle( + move || { + chain.complete_partial_beacon_block_gloas( + partial_beacon_block, + execution_payload_bid, + parent_execution_requests, + payload_data, + state, + verification, + ) + }, + "complete_partial_beacon_block_gloas", + ) + .ok_or(BlockProductionError::ShuttingDown)? + .await + .map_err(BlockProductionError::TokioJoin)? + } + + #[allow(clippy::too_many_arguments)] + #[allow(clippy::type_complexity)] + #[instrument(skip_all, level = "debug")] + fn produce_partial_beacon_block_gloas( + self: &Arc<Self>, + mut state: BeaconState<T::EthSpec>, + state_root_opt: Option<Hash256>, + produce_at_slot: Slot, + randao_reveal: Signature, + graffiti: Graffiti, + parent_execution_requests: &ExecutionRequests<T::EthSpec>, + ) -> Result<(PartialBeaconBlock<T::EthSpec>, BeaconState<T::EthSpec>), BlockProductionError> + { + // It is invalid to try to produce a block using a state from a future slot. + if state.slot() > produce_at_slot { + return Err(BlockProductionError::StateSlotTooHigh { + produce_at_slot, + state_slot: state.slot(), + }); + } + + let slot_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_SLOT_PROCESS_TIMES); + + // Ensure the state has performed a complete transition into the required slot. + complete_state_advance(&mut state, state_root_opt, produce_at_slot, &self.spec)?; + + drop(slot_timer); + + state.build_committee_cache(RelativeEpoch::Current, &self.spec)?; + state.apply_pending_mutations()?; + + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .map_err(|_| BlockProductionError::UnableToGetBlockRootFromState)? + } else { + state.latest_block_header().canonical_root() + }; + + let proposer_index = state.get_beacon_proposer_index(state.slot(), &self.spec)? as u64; + + let slashings_and_exits_span = debug_span!("get_slashings_and_exits").entered(); + let (mut proposer_slashings, mut attester_slashings, mut voluntary_exits) = + self.op_pool.get_slashings_and_exits(&state, &self.spec); + + filter_voluntary_exits_for_parent_execution_requests( + &mut voluntary_exits, + parent_execution_requests, + |idx| state.validators().get(idx as usize).map(|v| v.pubkey), + &self.spec, + ); + + drop(slashings_and_exits_span); + + let eth1_data = state.eth1_data().clone(); + + let deposits = vec![]; + + let bls_changes_span = debug_span!("get_bls_to_execution_changes").entered(); + let bls_to_execution_changes = self + .op_pool + .get_bls_to_execution_changes(&state, &self.spec); + drop(bls_changes_span); + + // Iterate through the naive aggregation pool and ensure all the attestations from there + // are included in the operation pool. + { + let _guard = debug_span!("import_naive_aggregation_pool").entered(); + let _unagg_import_timer = + metrics::start_timer(&metrics::BLOCK_PRODUCTION_UNAGGREGATED_TIMES); + for attestation in self.naive_aggregation_pool.read().iter() { + let import = |attestation: &Attestation<T::EthSpec>| { + let attesting_indices = + get_attesting_indices_from_state(&state, attestation.to_ref())?; + self.op_pool + .insert_attestation(attestation.clone(), attesting_indices) + }; + if let Err(e) = import(attestation) { + // Don't stop block production if there's an error, just create a log. + error!( + reason = ?e, + "Attestation did not transfer to op pool" + ); + } + } + }; + + let mut attestations = { + let _guard = debug_span!("pack_attestations").entered(); + let _attestation_packing_timer = + metrics::start_timer(&metrics::BLOCK_PRODUCTION_ATTESTATION_TIMES); + + // Epoch cache and total balance cache are required for op pool packing. + state.build_total_active_balance_cache(&self.spec)?; + initialize_epoch_cache(&mut state, &self.spec)?; + + let mut prev_filter_cache = HashMap::new(); + let prev_attestation_filter = |att: &CompactAttestationRef<T::EthSpec>| { + self.filter_op_pool_attestation(&mut prev_filter_cache, att, &state) + }; + let mut curr_filter_cache = HashMap::new(); + let curr_attestation_filter = |att: &CompactAttestationRef<T::EthSpec>| { + self.filter_op_pool_attestation(&mut curr_filter_cache, att, &state) + }; + + self.op_pool + .get_attestations( + &state, + prev_attestation_filter, + curr_attestation_filter, + &self.spec, + ) + .map_err(BlockProductionError::OpPoolError)? + }; + + let mut payload_attestations = self + .op_pool + .get_payload_attestations(&state, parent_root, &self.spec) + .map_err(BlockProductionError::OpPoolError)?; + + // If paranoid mode is enabled re-check the signatures of every included message. + // This will be a lot slower but guards against bugs in block production and can be + // quickly rolled out without a release. + if self.config.paranoid_block_proposal { + let mut tmp_ctxt = ConsensusContext::new(state.slot()); + attestations.retain(|att| { + verify_attestation_for_block_inclusion( + &state, + att.to_ref(), + &mut tmp_ctxt, + VerifySignatures::True, + &self.spec, + ) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + attestation = ?att, + "Attempted to include an invalid attestation" + ); + }) + .is_ok() + }); + + payload_attestations.retain(|att| { + match get_indexed_payload_attestation(&state, att, &self.spec) { + Ok(indexed) => is_valid_indexed_payload_attestation( + &state, + &indexed, + VerifySignatures::True, + &self.spec, + ) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?att, + "Attempted to include a payload attestation with invalid signature" + ); + }) + .is_ok(), + Err(e) => { + warn!( + err = ?e, + block_slot = %state.slot(), + ?att, + "Failed to index payload attestation for verification" + ); + false + } + } + }); + + proposer_slashings.retain(|slashing| { + slashing + .clone() + .validate(&state, &self.spec) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?slashing, + "Attempted to include an invalid proposer slashing" + ); + }) + .is_ok() + }); + + attester_slashings.retain(|slashing| { + slashing + .clone() + .validate(&state, &self.spec) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?slashing, + "Attempted to include an invalid attester slashing" + ); + }) + .is_ok() + }); + + voluntary_exits.retain(|exit| { + exit.clone() + .validate(&state, &self.spec) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?exit, + "Attempted to include an invalid voluntary exit" + ); + }) + .is_ok() + }); + } + + let attester_slashings = attester_slashings + .into_iter() + .filter_map(|a| match a { + AttesterSlashing::Base(_) => None, + AttesterSlashing::Electra(a) => Some(a), + }) + .collect::<Vec<_>>(); + + let attestations = attestations + .into_iter() + .filter_map(|a| match a { + Attestation::Base(_) => None, + Attestation::Electra(a) => Some(a), + }) + .collect::<Vec<_>>(); + + let slot = state.slot(); + + let sync_aggregate = self + .op_pool + .get_sync_aggregate(&state) + .map_err(BlockProductionError::OpPoolError)? + .unwrap_or_else(|| { + warn!( + slot = %state.slot(), + "Producing block with no sync contributions" + ); + SyncAggregate::new() + }); + + Ok(( + PartialBeaconBlock { + slot, + proposer_index, + parent_root, + randao_reveal, + eth1_data, + graffiti, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + sync_aggregate, + payload_attestations, + bls_to_execution_changes, + }, + state, + )) + } + + /// Complete a block by computing its state root, and + /// + /// Return `(block, post_block_state, block_value)` where: + /// + /// - `post_block_state` is the state post block application + /// - `block_value` is the consensus-layer rewards for `block` + #[allow(clippy::type_complexity)] + #[instrument(skip_all, level = "debug")] + fn complete_partial_beacon_block_gloas( + &self, + partial_beacon_block: PartialBeaconBlock<T::EthSpec>, + signed_execution_payload_bid: SignedExecutionPayloadBid<T::EthSpec>, + parent_execution_requests: ExecutionRequests<T::EthSpec>, + payload_data: Option<ExecutionPayloadData<T::EthSpec>>, + mut state: BeaconState<T::EthSpec>, + verification: ProduceBlockVerification, + ) -> Result<BlockProductionResult<T::EthSpec>, BlockProductionError> { + let PartialBeaconBlock { + slot, + proposer_index, + parent_root, + randao_reveal, + eth1_data, + graffiti, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + sync_aggregate, + payload_attestations, + bls_to_execution_changes, + } = partial_beacon_block; + + let beacon_block = match &state { + BeaconState::Base(_) + | BeaconState::Altair(_) + | BeaconState::Bellatrix(_) + | BeaconState::Capella(_) + | BeaconState::Deneb(_) + | BeaconState::Electra(_) + | BeaconState::Fulu(_) + | BeaconState::Eip7805(_) => { + return Err(BlockProductionError::InvalidBlockVariant( + "Cannot construct a block pre-Gloas".to_owned(), + )); + } + BeaconState::Gloas(_) => BeaconBlock::Gloas(BeaconBlockGloas { + slot, + proposer_index, + parent_root, + state_root: Hash256::ZERO, + body: BeaconBlockBodyGloas { + randao_reveal, + eth1_data, + graffiti, + proposer_slashings: proposer_slashings + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + attester_slashings: attester_slashings + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + attestations: attestations + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + deposits: deposits + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + voluntary_exits: voluntary_exits + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + sync_aggregate, + bls_to_execution_changes: bls_to_execution_changes + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + parent_execution_requests, + signed_execution_payload_bid, + payload_attestations: payload_attestations + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + _phantom: PhantomData::<FullPayload<T::EthSpec>>, + }, + }), + }; + + let signed_beacon_block = SignedBeaconBlock::from_block( + beacon_block, + // The block is not signed here, that is the task of a validator client. + Signature::empty(), + ); + + let block_size = signed_beacon_block.ssz_bytes_len(); + debug!(%block_size, "Produced block on state"); + + metrics::observe(&metrics::BLOCK_SIZE, block_size as f64); + + if block_size > self.config.max_network_size { + return Err(BlockProductionError::BlockTooLarge(block_size)); + } + + let process_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_PROCESS_TIMES); + let signature_strategy = match verification { + ProduceBlockVerification::VerifyRandao => BlockSignatureStrategy::VerifyRandao, + ProduceBlockVerification::NoVerification => BlockSignatureStrategy::NoVerification, + }; + + // Use a context without block root or proposer index so that both are checked. + let mut ctxt = ConsensusContext::new(signed_beacon_block.slot()); + + let consensus_block_value = self + .compute_beacon_block_reward(signed_beacon_block.message(), &mut state) + .map(|reward| reward.total) + .unwrap_or(0); + + state_processing::per_block_processing( + &mut state, + &signed_beacon_block, + signature_strategy, + VerifyBlockRoot::True, + &mut ctxt, + &self.spec, + )?; + drop(process_timer); + + let state_root_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_ROOT_TIMES); + + let state_root = state.update_tree_hash_cache()?; + + drop(state_root_timer); + + let (mut block, _) = signed_beacon_block.deconstruct(); + *block.state_root_mut() = state_root; + + // Construct and cache the ExecutionPayloadEnvelope if we have payload data. + // For local building, we always have payload data. + // For trustless building, the builder will provide the envelope separately. + if let Some(payload_data) = payload_data { + let beacon_block_root = block.tree_hash_root(); + let parent_beacon_block_root = block.parent_root(); + let execution_payload_envelope = ExecutionPayloadEnvelope { + payload: payload_data.payload, + execution_requests: payload_data.execution_requests, + builder_index: payload_data.builder_index, + beacon_block_root, + parent_beacon_block_root, + }; + + let signed_envelope = SignedExecutionPayloadEnvelope { + message: execution_payload_envelope, + signature: Signature::empty(), + }; + + // Verify the envelope against the state. This performs no state mutation. + verify_execution_payload_envelope( + &state, + &signed_envelope, + VerifySignatures::False, + state_root, + &self.spec, + ) + .map_err(BlockProductionError::EnvelopeProcessingError)?; + + // Cache the envelope for later retrieval by the validator for signing and publishing. + let envelope_slot = payload_data.slot; + // TODO(gloas) might be safer to cache by root instead of by slot. + // We should revisit this once this code path + beacon api spec matures + let (blobs, _) = payload_data.blobs_and_proofs; + self.pending_payload_envelopes.write().insert( + envelope_slot, + PendingEnvelopeData { + envelope: signed_envelope.message, + blobs: Some(blobs), + }, + ); + + debug!( + %beacon_block_root, + slot = %envelope_slot, + "Cached pending execution payload envelope" + ); + } + + metrics::inc_counter(&metrics::BLOCK_PRODUCTION_SUCCESSES); + + trace!( + parent = ?block.parent_root(), + attestations = block.body().attestations_len(), + slot = %block.slot(), + "Produced beacon block" + ); + + Ok((block, state, consensus_block_value)) + } + + /// Produce a self-build `ExecutionPayloadBid` for some `slot` upon the given `state`. + /// This function assumes we've already advanced `state`. + /// + /// Returns the signed bid, the state, and a `LocalBuildResult` carrying the payload + /// data needed to construct the `ExecutionPayloadEnvelope` after the beacon block is + /// created, plus the EL block value and `should_override_builder` flag used by the + /// caller to compare against any cached p2p builder bid. + #[allow(clippy::type_complexity)] + #[instrument(level = "debug", skip_all)] + pub async fn produce_execution_payload_bid( + self: Arc<Self>, + state: BeaconState<T::EthSpec>, + parent_payload_status: PayloadStatus, + parent_envelope: Option<Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>>, + produce_at_slot: Slot, + bid_value: u64, + builder_index: BuilderIndex, + ) -> Result< + ( + SignedExecutionPayloadBid<T::EthSpec>, + BeaconState<T::EthSpec>, + LocalBuildResult<T::EthSpec>, + ), + BlockProductionError, + > { + // TODO(gloas) For non local building, add sanity check on value + // The builder MUST have enough excess balance to fulfill this bid (i.e. `value`) and all pending payments. + + // TODO(gloas) add metrics for execution payload bid production + + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .map_err(|_| BlockProductionError::UnableToGetBlockRootFromState)? + } else { + state.latest_block_header().canonical_root() + }; + + let proposer_index = state.get_beacon_proposer_index(state.slot(), &self.spec)? as u64; + + let pubkey = state + .validators() + .get(proposer_index as usize) + .map(|v| v.pubkey) + .ok_or(BlockProductionError::BeaconChain(Box::new( + BeaconChainError::ValidatorIndexUnknown(proposer_index as usize), + )))?; + + let builder_params = BuilderParams { + pubkey, + slot: state.slot(), + chain_health: self + .is_healthy(&parent_root) + .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?, + }; + + let parent_bid = state.latest_execution_payload_bid()?; + + // TODO(gloas): need should_extend_payload check here as well + let parent_block_slot = state.latest_block_header().slot; + let parent_is_pre_gloas = !self + .spec + .fork_name_at_slot::<T::EthSpec>(parent_block_slot) + .gloas_enabled(); + let parent_block_hash = + if parent_payload_status == PayloadStatus::Full || parent_is_pre_gloas { + // Build on parent bid's payload. + parent_bid.block_hash + } else { + // Skip parent bid's payload. For genesis this is the EL genesis hash. + parent_bid.parent_block_hash + }; + + // TODO(gloas) this should be BlockProductionVersion::V4 + // V3 is okay for now as long as we're not connected to a builder + // TODO(gloas) add builder boost factor + let prepare_payload_handle = get_execution_payload_gloas( + self.clone(), + &state, + parent_root, + parent_block_hash, + parent_envelope, + proposer_index, + builder_params, + )?; + + let block_proposal_contents = prepare_payload_handle + .await + .map_err(BlockProductionError::TokioJoin)? + .ok_or(BlockProductionError::ShuttingDown)??; + + let BlockProposalContentsGloas { + payload, + payload_value, + execution_requests, + blob_kzg_commitments, + blobs_and_proofs, + should_override_builder, + } = block_proposal_contents; + + // TODO(gloas) since we are defaulting to local building, execution payment is 0 + // execution payment should only be set to > 0 for trusted building. + let bid = ExecutionPayloadBid::<T::EthSpec> { + parent_block_hash, + parent_block_root: parent_root, + block_hash: payload.block_hash, + prev_randao: payload.prev_randao, + fee_recipient: Address::ZERO, + gas_limit: payload.gas_limit, + builder_index, + slot: produce_at_slot, + value: bid_value, + execution_payment: EXECUTION_PAYMENT_TRUSTLESS_BUILD, + blob_kzg_commitments, + execution_requests_root: execution_requests.tree_hash_root(), + }; + + // Store payload data for envelope construction after block is created + let payload_data = ExecutionPayloadData { + payload, + execution_requests, + builder_index, + slot: produce_at_slot, + blobs_and_proofs, + }; + + Ok(( + SignedExecutionPayloadBid { + message: bid, + signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, + }, + state, + LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + }, + )) + } + + /// Look up the highest gossip-verified bid for the `(slot, parent_block_hash, + /// parent_block_root)` of the local bid, then choose the winner. + fn select_payload_bid( + &self, + local_signed_bid: SignedExecutionPayloadBid<T::EthSpec>, + local_build: LocalBuildResult<T::EthSpec>, + builder_boost_factor: Option<u64>, + ) -> ( + SignedExecutionPayloadBid<T::EthSpec>, + Option<ExecutionPayloadData<T::EthSpec>>, + ) { + let cached_bid = self.gossip_verified_payload_bid_cache.get_highest_bid( + local_signed_bid.message.slot, + local_signed_bid.message.parent_block_hash, + local_signed_bid.message.parent_block_root, + ); + select_payload_bid_pure( + local_signed_bid, + local_build, + cached_bid, + builder_boost_factor, + ) + } +} + +/// Pure local-vs-cached selection logic, factored out for unit testing. +/// +/// Selection rule (mirrors the pre-Gloas builder/local race in `execution_layer`): +/// - `boosted_bid = (cached_bid.value / 100) * builder_boost_factor` (raw value when `None`) +/// - if `local_value_wei >= boosted_bid_wei` → keep local +/// - if the EL signaled `should_override_builder` → keep local +/// - otherwise → use the cached builder bid and drop local payload data +/// (the builder is responsible for revealing the envelope). +/// +/// `cached_bid.value` is in gwei (`u64`); `payload_value` is in wei (`Uint256`); compared in wei. +pub(crate) fn select_payload_bid_pure<E: EthSpec>( + local_signed_bid: SignedExecutionPayloadBid<E>, + local_build: LocalBuildResult<E>, + cached_bid: Option<Arc<SignedExecutionPayloadBid<E>>>, + builder_boost_factor: Option<u64>, +) -> ( + SignedExecutionPayloadBid<E>, + Option<ExecutionPayloadData<E>>, +) { + let LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + } = local_build; + + let Some(cached_bid) = cached_bid else { + return (local_signed_bid, Some(payload_data)); + }; + + let slot = local_signed_bid.message.slot; + + if should_override_builder { + debug!( + %slot, + cached_bid_value = cached_bid.message.value, + "Using local payload because EL signaled shouldOverrideBuilder" + ); + return (local_signed_bid, Some(payload_data)); + } + + // Convert bid value (gwei) to wei for comparison with `payload_value` (wei). + let bid_value_wei = types::Uint256::from(cached_bid.message.value) + .saturating_mul(types::Uint256::from(1_000_000_000u64)); + let boosted_bid_wei = match builder_boost_factor { + Some(factor) => { + (bid_value_wei / types::Uint256::from(100)).saturating_mul(types::Uint256::from(factor)) + } + None => bid_value_wei, + }; + + if payload_value >= boosted_bid_wei { + debug!( + %slot, + %payload_value, + cached_bid_value_gwei = cached_bid.message.value, + ?builder_boost_factor, + "Local payload is more profitable than cached builder bid" + ); + (local_signed_bid, Some(payload_data)) + } else { + debug!( + %slot, + %payload_value, + cached_bid_value_gwei = cached_bid.message.value, + cached_bid_builder_index = cached_bid.message.builder_index, + ?builder_boost_factor, + "Including cached builder bid" + ); + ((*cached_bid).clone(), None) + } +} + +/// Gets an execution payload for inclusion in a block. +/// +/// ## Errors +/// +/// Will return an error when using a pre-Gloas `state`. Ensure to only run this function +/// after the Gloas fork. +fn get_execution_payload_gloas<T: BeaconChainTypes>( + chain: Arc<BeaconChain<T>>, + state: &BeaconState<T::EthSpec>, + parent_beacon_block_root: Hash256, + parent_block_hash: ExecutionBlockHash, + parent_envelope: Option<Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>>, + proposer_index: u64, + builder_params: BuilderParams, +) -> Result<PreparePayloadHandle<T::EthSpec>, BlockProductionError> { + // Compute all required values from the `state` now to avoid needing to pass it into a spawned + // task. + let spec = &chain.spec; + let current_epoch = state.current_epoch(); + let timestamp = + compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; + let random = *state.get_randao_mix(current_epoch)?; + + // TODO(gloas): this gas limit calc is not necessarily right + let parent_bid = state.latest_execution_payload_bid()?; + let latest_gas_limit = parent_bid.gas_limit; + + let is_parent_block_full = parent_block_hash == parent_bid.block_hash; + + let withdrawals = if is_parent_block_full { + if let Some(envelope) = parent_envelope { + let mut withdrawals_state = state.clone(); + apply_parent_execution_payload( + &mut withdrawals_state, + &envelope.message.execution_requests, + spec, + )?; + Withdrawals::<T::EthSpec>::from(get_expected_withdrawals(&withdrawals_state, spec)?) + .into() + } else { + // No envelope available (e.g. genesis). The parent had no execution requests, + // so compute withdrawals directly from the current state. + Withdrawals::<T::EthSpec>::from(get_expected_withdrawals(state, spec)?).into() + } + } else { + // If the previous payload was missed, carry forward the withdrawals from the state. + state.payload_expected_withdrawals()?.to_vec() + }; + + // Spawn a task to obtain the execution payload from the EL via a series of async calls. The + // `join_handle` can be used to await the result of the function. + let join_handle = chain + .task_executor + .clone() + .spawn_handle( + async move { + prepare_execution_payload::<T>( + &chain, + timestamp, + random, + proposer_index, + parent_block_hash, + latest_gas_limit, + builder_params, + withdrawals, + parent_beacon_block_root, + ) + .await + } + .instrument(debug_span!("prepare_execution_payload")), + "prepare_execution_payload", + ) + .ok_or(BlockProductionError::ShuttingDown)?; + + Ok(join_handle) +} + +/// Prepares an execution payload for inclusion in a block. +/// +/// ## Errors +/// +/// Will return an error when using a pre-Gloas fork `state`. Ensure to only run this function +/// after the Gloas fork. +#[allow(clippy::too_many_arguments)] +async fn prepare_execution_payload<T>( + chain: &Arc<BeaconChain<T>>, + timestamp: u64, + random: Hash256, + proposer_index: u64, + parent_block_hash: ExecutionBlockHash, + parent_gas_limit: u64, + builder_params: BuilderParams, + withdrawals: Vec<Withdrawal>, + parent_beacon_block_root: Hash256, +) -> Result<BlockProposalContentsGloas<T::EthSpec>, BlockProductionError> +where + T: BeaconChainTypes, +{ + let spec = &chain.spec; + let fork = spec.fork_name_at_slot::<T::EthSpec>(builder_params.slot); + let execution_layer = chain + .execution_layer + .as_ref() + .ok_or(BlockProductionError::ExecutionLayerMissing)?; + + // Try to obtain the fork choice update parameters from the cached head. + // + // Use a blocking task to interact with the `canonical_head` lock otherwise we risk blocking the + // core `tokio` executor. + let inner_chain = chain.clone(); + let forkchoice_update_params = chain + .spawn_blocking_handle( + move || { + inner_chain + .canonical_head + .cached_head() + .forkchoice_update_parameters() + }, + "prepare_execution_payload_forkchoice_update_params", + ) + .instrument(debug_span!("forkchoice_update_params")) + .await + .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?; + + let suggested_fee_recipient = execution_layer + .get_suggested_fee_recipient(proposer_index) + .await; + let slot_number = Some(builder_params.slot.as_u64()); + + let payload_attributes = PayloadAttributes::new( + timestamp, + random, + suggested_fee_recipient, + Some(withdrawals), + Some(parent_beacon_block_root), + slot_number, + ); + + let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; + let payload_parameters = PayloadParameters { + parent_hash: parent_block_hash, + parent_gas_limit, + proposer_gas_limit: target_gas_limit, + payload_attributes: &payload_attributes, + forkchoice_update_params: &forkchoice_update_params, + current_fork: fork, + }; + + let block_contents = execution_layer + .get_payload_gloas(payload_parameters) + .await + .map_err(BlockProductionError::GetPayloadFailed)?; + + Ok(block_contents) +} + +/// Drop voluntary exits whose target validators will be exited by the parent envelope's +/// execution requests. +/// +/// In Gloas the parent execution payload is processed before voluntary exits during block +/// processing. EL-triggered withdrawal-full-exit requests (EIP-7002) and cross-pubkey +/// consolidation requests (EIP-7251) call `initiate_validator_exit`, setting the target's +/// `exit_epoch`. A voluntary exit for the same validator would then fail with `AlreadyExited`. +fn filter_voluntary_exits_for_parent_execution_requests<E: EthSpec>( + voluntary_exits: &mut Vec<SignedVoluntaryExit>, + parent_execution_requests: &ExecutionRequests<E>, + pubkey_at_index: impl Fn(u64) -> Option<PublicKeyBytes>, + spec: &ChainSpec, +) { + let mut exited_pubkeys = HashSet::with_capacity( + parent_execution_requests.withdrawals.len() + + parent_execution_requests.consolidations.len(), + ); + for req in &parent_execution_requests.withdrawals { + if req.amount == spec.full_exit_request_amount { + exited_pubkeys.insert(req.validator_pubkey); + } + } + for req in &parent_execution_requests.consolidations { + if req.source_pubkey != req.target_pubkey { + exited_pubkeys.insert(req.source_pubkey); + } + } + if !exited_pubkeys.is_empty() { + voluntary_exits.retain(|exit| { + pubkey_at_index(exit.message.validator_index) + .map(|pk| !exited_pubkeys.contains(&pk)) + .unwrap_or(false) + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ssz_types::VariableList; + use types::{ConsolidationRequest, Epoch, MainnetEthSpec, VoluntaryExit, WithdrawalRequest}; + + type TestSpec = MainnetEthSpec; + + fn pubkey(byte: u8) -> PublicKeyBytes { + PublicKeyBytes::deserialize(&[byte; 48]).expect("valid pubkey byte length") + } + + fn exit(validator_index: u64) -> SignedVoluntaryExit { + SignedVoluntaryExit { + message: VoluntaryExit { + epoch: Epoch::new(0), + validator_index, + }, + signature: Signature::empty(), + } + } + + fn requests( + withdrawals: Vec<WithdrawalRequest>, + consolidations: Vec<ConsolidationRequest>, + ) -> ExecutionRequests<TestSpec> { + ExecutionRequests { + deposits: VariableList::empty(), + withdrawals: VariableList::new(withdrawals).unwrap(), + consolidations: VariableList::new(consolidations).unwrap(), + } + } + + fn run_filter( + exits: &mut Vec<SignedVoluntaryExit>, + requests: &ExecutionRequests<TestSpec>, + validator_pubkeys: &[PublicKeyBytes], + spec: &ChainSpec, + ) { + filter_voluntary_exits_for_parent_execution_requests( + exits, + requests, + |idx| validator_pubkeys.get(idx as usize).copied(), + spec, + ); + } + + #[test] + fn full_exit_withdrawal_request_filters_matching_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2)]; + let mut exits = vec![exit(0), exit(1)]; + let reqs = requests( + vec![WithdrawalRequest { + source_address: Address::repeat_byte(0xaa), + validator_pubkey: validators[0], + amount: spec.full_exit_request_amount, + }], + vec![], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + assert_eq!(exits[0].message.validator_index, 1); + } + + #[test] + fn partial_withdrawal_request_does_not_filter_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1)]; + let mut exits = vec![exit(0)]; + let reqs = requests( + vec![WithdrawalRequest { + source_address: Address::repeat_byte(0xaa), + validator_pubkey: validators[0], + amount: spec.full_exit_request_amount + 1, + }], + vec![], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + } + + #[test] + fn cross_pubkey_consolidation_filters_voluntary_exit_for_source_only() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2), pubkey(3)]; + let mut exits = vec![exit(0), exit(1), exit(2)]; + let reqs = requests( + vec![], + vec![ConsolidationRequest { + source_address: Address::repeat_byte(0xaa), + source_pubkey: validators[1], + target_pubkey: validators[2], + }], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + // The source (validator 1) is exited; the target (validator 2) is not. + let remaining: Vec<u64> = exits.iter().map(|e| e.message.validator_index).collect(); + assert_eq!(remaining, vec![0, 2]); + } + + #[test] + fn self_consolidation_does_not_filter_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1)]; + let mut exits = vec![exit(0)]; + let reqs = requests( + vec![], + vec![ConsolidationRequest { + source_address: Address::repeat_byte(0xaa), + source_pubkey: validators[0], + target_pubkey: validators[0], + }], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + } + + #[test] + fn empty_parent_requests_preserve_voluntary_exits() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2)]; + let mut exits = vec![exit(0), exit(1)]; + let reqs = requests(vec![], vec![]); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 2); + } + + // ---- select_payload_bid_pure ---- + + const REMOTE_BUILDER: BuilderIndex = 999; + + fn gwei(n: u64) -> types::Uint256 { + types::Uint256::from(n).saturating_mul(types::Uint256::from(1_000_000_000u64)) + } + + fn local_bid() -> SignedExecutionPayloadBid<TestSpec> { + SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index: BUILDER_INDEX_SELF_BUILD, + ..Default::default() + }, + signature: Signature::empty(), + } + } + + fn cached_bid(value_gwei: u64) -> Arc<SignedExecutionPayloadBid<TestSpec>> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index: REMOTE_BUILDER, + value: value_gwei, + ..Default::default() + }, + signature: Signature::empty(), + }) + } + + fn local_build(payload_gwei: u64, should_override_builder: bool) -> LocalBuildResult<TestSpec> { + LocalBuildResult { + payload_data: ExecutionPayloadData { + payload: types::ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: BUILDER_INDEX_SELF_BUILD, + slot: Slot::new(0), + blobs_and_proofs: (VariableList::empty(), VariableList::empty()), + }, + payload_value: gwei(payload_gwei), + should_override_builder, + } + } + + const LOCAL: BuilderIndex = BUILDER_INDEX_SELF_BUILD; + const REMOTE: BuilderIndex = REMOTE_BUILDER; + + /// Run `select_payload_bid_pure` and return `(winning_builder_index, has_payload_data)`. + /// + /// Args (positional, mirror `select_payload_bid_pure`): + /// - `local_payload_gwei`: local payload value, in gwei. + /// - `should_override`: EL's `shouldOverrideBuilder` flag. + /// - `cached_gwei`: `Some(g)` ⇒ seed the cache with a bid of `g` gwei. + /// - `boost`: `None` = neutral, `Some(0)` = always local, `Some(>100)` = boost bid. + fn pick( + local_payload_gwei: u64, + should_override: bool, + cached_gwei: Option<u64>, + boost: Option<u64>, + ) -> (BuilderIndex, bool) { + let build = local_build(local_payload_gwei, should_override); + let cache = cached_gwei.map(cached_bid); + let (out, data) = select_payload_bid_pure::<TestSpec>(local_bid(), build, cache, boost); + (out.message.builder_index, data.is_some()) + } + + #[test] + fn select_empty_cache_keeps_local() { + assert_eq!(pick(0, false, None, Some(u64::MAX)), (LOCAL, true)); + } + + #[test] + fn select_el_override_beats_any_cached_bid() { + // `shouldOverrideBuilder` short-circuits regardless of cache or boost. + assert_eq!(pick(0, true, Some(u64::MAX), Some(u64::MAX)), (LOCAL, true)); + } + + #[test] + fn select_boost_zero_always_keeps_local() { + // boost=0 deflates the bid to 0 ⇒ local always wins. + assert_eq!(pick(0, false, Some(u64::MAX), Some(0)), (LOCAL, true)); + } + + #[test] + fn select_neutral_boost_picks_higher_bid() { + // 5 gwei bid > 1 gwei local, neutral compare ⇒ bid. + assert_eq!(pick(1, false, Some(5), None), (REMOTE, false)); + } + + #[test] + fn select_local_strictly_higher_keeps_local() { + assert_eq!(pick(10, false, Some(5), None), (LOCAL, true)); + } + + #[test] + fn select_tie_goes_to_local() { + // `>=` ⇒ local wins ties. + assert_eq!(pick(5, false, Some(5), None), (LOCAL, true)); + } + + #[test] + fn select_boost_factor_amplifies_bid() { + // 5 gwei local vs 3 gwei bid: raw ⇒ local. + assert_eq!(pick(5, false, Some(3), None), (LOCAL, true)); + // boost=200 ⇒ bid scaled to 6 gwei ⇒ bid wins. + assert_eq!(pick(5, false, Some(3), Some(200)), (REMOTE, false)); + } +} diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs new file mode 100644 index 0000000000..fd5e381023 --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -0,0 +1,264 @@ +use std::{sync::Arc, time::Duration}; + +use fork_choice::PayloadStatus; +use proto_array::ProposerHeadError; +use slot_clock::SlotClock; +use tracing::{debug, error, info, instrument, warn}; +use types::{BeaconState, Hash256, SignedExecutionPayloadEnvelope, Slot}; + +use crate::{ + BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, + fork_choice_signal::ForkChoiceWaitResult, metrics, +}; + +mod gloas; + +/// State loaded from the database for block production. +pub(crate) struct BlockProductionState<E: types::EthSpec> { + pub state: BeaconState<E>, + pub state_root: Option<Hash256>, + pub parent_payload_status: PayloadStatus, + pub parent_envelope: Option<Arc<SignedExecutionPayloadEnvelope<E>>>, +} + +impl<T: BeaconChainTypes> BeaconChain<T> { + /// Load a beacon state from the database for block production. This is a long-running process + /// that should not be performed in an `async` context. + /// + /// The returned `PayloadStatus` is the payload status of the parent block to be built upon. + #[instrument(skip_all, level = "debug")] + pub(crate) fn load_state_for_block_production( + self: &Arc<Self>, + slot: Slot, + ) -> Result<BlockProductionState<T::EthSpec>, BlockProductionError> { + let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_FORK_CHOICE_TIMES); + self.wait_for_fork_choice_before_block_production(slot)?; + drop(fork_choice_timer); + + let state_load_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_LOAD_TIMES); + + // Atomically read some values from the head whilst avoiding holding cached head `Arc` any + // longer than necessary. If the head has a payload envelope (Gloas full head), cheaply + // clone the `Arc` so we can pass it to block production without a DB load. + let (head_slot, head_block_root, head_state_root, head_payload_status, head_envelope) = { + let head = self.canonical_head.cached_head(); + ( + head.head_slot(), + head.head_block_root(), + head.head_state_root(), + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), + ) + }; + let result = if head_slot < slot { + // Attempt an aggressive re-org if configured and the conditions are right. + // TODO(gloas): re-enable reorgs + let gloas_enabled = self + .spec + .fork_name_at_slot::<T::EthSpec>(slot) + .gloas_enabled(); + if !gloas_enabled + && let Some((re_org_state, re_org_state_root)) = + self.get_state_for_re_org(slot, head_slot, head_block_root) + { + info!( + %slot, + head_to_reorg = %head_block_root, + "Proposing block to re-org current head" + ); + // TODO(gloas): ensure we use a sensible payload status when we enable reorgs + // for Gloas + BlockProductionState { + state: re_org_state, + state_root: Some(re_org_state_root), + parent_payload_status: PayloadStatus::Pending, + parent_envelope: None, + } + } else { + // Fetch the head state advanced through to `slot`, which should be present in the + // state cache thanks to the state advance timer. + let parent_state_root = head_state_root; + let (state_root, state) = self + .store + .get_advanced_hot_state(head_block_root, slot, parent_state_root) + .map_err(BlockProductionError::FailedToLoadState)? + .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; + BlockProductionState { + state, + state_root: Some(state_root), + parent_payload_status: head_payload_status, + parent_envelope: head_envelope, + } + } + } else { + warn!( + message = "this block is more likely to be orphaned", + %slot, + "Producing block that conflicts with head" + ); + let state = self + .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) + .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; + + // TODO(gloas): update this to read payload canonicity from fork choice once ready + let parent_payload_status = PayloadStatus::Pending; + BlockProductionState { + state, + state_root: None, + parent_payload_status, + parent_envelope: None, + } + }; + + drop(state_load_timer); + + Ok(result) + } + + /// If configured, wait for the fork choice run at the start of the slot to complete. + #[instrument(level = "debug", skip_all)] + fn wait_for_fork_choice_before_block_production( + self: &Arc<Self>, + slot: Slot, + ) -> Result<(), BlockProductionError> { + if let Some(rx) = &self.fork_choice_signal_rx { + let current_slot = self + .slot() + .map_err(|_| BlockProductionError::UnableToReadSlot)?; + + let timeout = Duration::from_millis(self.config.fork_choice_before_proposal_timeout_ms); + + if slot == current_slot || slot == current_slot + 1 { + match rx.wait_for_fork_choice(slot, timeout) { + ForkChoiceWaitResult::Success(fc_slot) => { + debug!( + %slot, + fork_choice_slot = %fc_slot, + "Fork choice successfully updated before block production" + ); + } + ForkChoiceWaitResult::Behind(fc_slot) => { + warn!( + fork_choice_slot = %fc_slot, + %slot, + message = "this block may be orphaned", + "Fork choice notifier out of sync with block production" + ); + } + ForkChoiceWaitResult::TimeOut => { + warn!( + message = "this block may be orphaned", + "Timed out waiting for fork choice before proposal" + ); + } + } + } else { + error!( + %slot, + %current_slot, + message = "check clock sync, this block may be orphaned", + "Producing block at incorrect slot" + ); + } + } + Ok(()) + } + + /// Fetch the beacon state to use for producing a block if a 1-slot proposer re-org is viable. + /// + /// This function will return `None` if proposer re-orgs are disabled. + #[instrument(skip_all, level = "debug")] + fn get_state_for_re_org( + &self, + slot: Slot, + head_slot: Slot, + canonical_head: Hash256, + ) -> Option<(BeaconState<T::EthSpec>, Hash256)> { + let re_org_head_threshold = self.config.re_org_head_threshold?; + let re_org_parent_threshold = self.config.re_org_parent_threshold?; + + if self.spec.proposer_score_boost.is_none() { + warn!( + reason = "this network does not have proposer boost enabled", + "Ignoring proposer re-org configuration" + ); + return None; + } + + let slot_delay = self + .slot_clock + .seconds_from_current_slot_start() + .or_else(|| { + warn!(error = "unable to read slot clock", "Not attempting re-org"); + None + })?; + + // Attempt a proposer re-org if: + // + // 1. It seems we have time to propagate and still receive the proposer boost. + // 2. The current head block was seen late. + // 3. The `get_proposer_head` conditions from fork choice pass. + let proposing_on_time = + slot_delay < self.config.re_org_cutoff(self.spec.get_slot_duration()); + if !proposing_on_time { + debug!(reason = "not proposing on time", "Not attempting re-org"); + return None; + } + + let head_late = self.block_observed_after_attestation_deadline(canonical_head, head_slot); + if !head_late { + debug!(reason = "head not late", "Not attempting re-org"); + return None; + } + + // Is the current head weak and appropriate for re-orging? + let proposer_head_timer = + metrics::start_timer(&metrics::BLOCK_PRODUCTION_GET_PROPOSER_HEAD_TIMES); + let proposer_head = self + .canonical_head + .fork_choice_read_lock() + .get_proposer_head( + slot, + canonical_head, + re_org_head_threshold, + re_org_parent_threshold, + &self.config.re_org_disallowed_offsets, + self.config.re_org_max_epochs_since_finalization, + ) + .map_err(|e| match e { + ProposerHeadError::DoNotReOrg(reason) => { + debug!( + %reason, + "Not attempting re-org" + ); + } + ProposerHeadError::Error(e) => { + warn!( + error = ?e, + "Not attempting re-org" + ); + } + }) + .ok()?; + drop(proposer_head_timer); + let re_org_parent_block = proposer_head.parent_node.root(); + + let (state_root, state) = self + .store + .get_advanced_hot_state_from_cache(re_org_parent_block, slot) + .or_else(|| { + warn!(reason = "no state in cache", "Not attempting re-org"); + None + })?; + + info!( + weak_head = ?canonical_head, + parent = ?re_org_parent_block, + head_weight = proposer_head.head_node.weight(), + threshold_weight = proposer_head.re_org_head_weight_threshold, + "Attempting re-org due to weak head" + ); + + Some((state, state_root)) + } +} diff --git a/beacon_node/beacon_chain/src/block_reward.rs b/beacon_node/beacon_chain/src/block_reward.rs deleted file mode 100644 index f3924bb473..0000000000 --- a/beacon_node/beacon_chain/src/block_reward.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use eth2::lighthouse::{AttestationRewards, BlockReward, BlockRewardMeta}; -use operation_pool::{ - AttMaxCover, MaxCover, PROPOSER_REWARD_DENOMINATOR, RewardCache, SplitAttestation, -}; -use state_processing::{ - common::get_attesting_indices_from_state, - per_block_processing::altair::sync_committee::compute_sync_aggregate_rewards, -}; -use types::{AbstractExecPayload, BeaconBlockRef, BeaconState, EthSpec, Hash256}; - -impl<T: BeaconChainTypes> BeaconChain<T> { - pub fn compute_block_reward<Payload: AbstractExecPayload<T::EthSpec>>( - &self, - block: BeaconBlockRef<'_, T::EthSpec, Payload>, - block_root: Hash256, - state: &BeaconState<T::EthSpec>, - reward_cache: &mut RewardCache, - include_attestations: bool, - ) -> Result<BlockReward, BeaconChainError> { - if block.slot() != state.slot() { - return Err(BeaconChainError::BlockRewardSlotError); - } - - reward_cache.update(state)?; - - let total_active_balance = state.get_total_active_balance()?; - - let split_attestations = block - .body() - .attestations() - .map(|att| { - let attesting_indices = get_attesting_indices_from_state(state, att)?; - Ok(SplitAttestation::new( - att.clone_as_attestation(), - attesting_indices, - )) - }) - .collect::<Result<Vec<_>, BeaconChainError>>()?; - - let mut per_attestation_rewards = split_attestations - .iter() - .map(|att| { - AttMaxCover::new( - att.as_ref(), - state, - reward_cache, - total_active_balance, - &self.spec, - ) - .ok_or(BeaconChainError::BlockRewardAttestationError) - }) - .collect::<Result<Vec<_>, _>>()?; - - // Update the attestation rewards for each previous attestation included. - // This is O(n^2) in the number of attestations n. - for i in 0..per_attestation_rewards.len() { - let (updated, to_update) = per_attestation_rewards.split_at_mut(i + 1); - let latest_att = &updated[i]; - - for att in to_update { - att.update_covering_set(latest_att.intermediate(), latest_att.covering_set()); - } - } - - let mut prev_epoch_total = 0; - let mut curr_epoch_total = 0; - - for cover in &per_attestation_rewards { - if cover.att.data.slot.epoch(T::EthSpec::slots_per_epoch()) == state.current_epoch() { - curr_epoch_total += cover.score() as u64; - } else { - prev_epoch_total += cover.score() as u64; - } - } - - let attestation_total = prev_epoch_total + curr_epoch_total; - - // Drop the covers. - let per_attestation_rewards = per_attestation_rewards - .into_iter() - .map(|cover| { - // Divide each reward numerator by the denominator. This can lead to the total being - // less than the sum of the individual rewards due to the fact that integer division - // does not distribute over addition. - let mut rewards = cover.fresh_validators_rewards; - rewards - .values_mut() - .for_each(|reward| *reward /= PROPOSER_REWARD_DENOMINATOR); - rewards - }) - .collect(); - - // Add the attestation data if desired. - let attestations = if include_attestations { - block - .body() - .attestations() - .map(|a| a.data().clone()) - .collect() - } else { - vec![] - }; - - let attestation_rewards = AttestationRewards { - total: attestation_total, - prev_epoch_total, - curr_epoch_total, - per_attestation_rewards, - attestations, - }; - - // Sync committee rewards. - let sync_committee_rewards = if let Ok(sync_aggregate) = block.body().sync_aggregate() { - let (_, proposer_reward_per_bit) = compute_sync_aggregate_rewards(state, &self.spec) - .map_err(|_| BeaconChainError::BlockRewardSyncError)?; - sync_aggregate.sync_committee_bits.num_set_bits() as u64 * proposer_reward_per_bit - } else { - 0 - }; - - // Total, metadata - let total = attestation_total + sync_committee_rewards; - - let meta = BlockRewardMeta { - slot: block.slot(), - parent_slot: state.latest_block_header().slot, - proposer_index: block.proposer_index(), - graffiti: block.body().graffiti().as_utf8_lossy(), - }; - - Ok(BlockReward { - total, - block_root, - meta, - attestation_rewards, - sync_committee_rewards, - }) - } -} diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index bca8d2bc57..9a43147233 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -50,12 +50,13 @@ use crate::beacon_snapshot::PreProcessingSnapshot; use crate::blob_verification::GossipBlobError; -use crate::block_verification_types::{AsBlock, BlockImportData, RpcBlock}; -use crate::data_availability_checker::{AvailabilityCheckError, MaybeAvailableBlock}; +use crate::block_verification_types::{AsBlock, BlockImportData, LookupBlock, RangeSyncBlock}; +use crate::data_availability_checker::{ + AvailabilityCheckError, AvailableBlock, AvailableBlockData, MaybeAvailableBlock, +}; use crate::data_column_verification::GossipDataColumnError; use crate::execution_payload::{ - AllowOptimisticImport, NotifyExecutionLayer, PayloadNotifier, - validate_execution_payload_for_gossip, validate_merge_block, + NotifyExecutionLayer, PayloadNotifier, validate_execution_payload_for_gossip, }; use crate::kzg_utils::blobs_to_data_column_sidecars; use crate::observed_block_producers::SeenBlock; @@ -78,7 +79,7 @@ use safe_arith::ArithError; use slot_clock::SlotClock; use ssz::Encode; use ssz_derive::{Decode, Encode}; -use state_processing::per_block_processing::{errors::IntoWithIndex, is_merge_transition_block}; +use state_processing::per_block_processing::errors::IntoWithIndex; use state_processing::{ AllCaches, BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError, VerifyBlockRoot, @@ -97,35 +98,10 @@ use task_executor::JoinHandle; use tracing::{Instrument, Span, debug, debug_span, error, info_span, instrument}; use types::{ BeaconBlockRef, BeaconState, BeaconStateError, BlobsList, ChainSpec, DataColumnSidecarList, - Epoch, EthSpec, ExecutionBlockHash, FullPayload, Hash256, InconsistentFork, KzgProofs, - RelativeEpoch, SignedBeaconBlock, SignedBeaconBlockHeader, Slot, - data_column_sidecar::DataColumnSidecarError, + Epoch, EthSpec, FullPayload, Hash256, InconsistentFork, KzgProofs, RelativeEpoch, + SignedBeaconBlock, SignedBeaconBlockHeader, Slot, data::DataColumnSidecarError, }; -pub const POS_PANDA_BANNER: &str = r#" - ,,, ,,, ,,, ,,, - ;" ^; ;' ", ;" ^; ;' ", - ; s$$$$$$$s ; ; s$$$$$$$s ; - , ss$$$$$$$$$$s ,' ooooooooo. .oooooo. .oooooo..o , ss$$$$$$$$$$s ,' - ;s$$$$$$$$$$$$$$$ `888 `Y88. d8P' `Y8b d8P' `Y8 ;s$$$$$$$$$$$$$$$ - $$$$$$$$$$$$$$$$$$ 888 .d88'888 888Y88bo. $$$$$$$$$$$$$$$$$$ - $$$$P""Y$$$Y""W$$$$$ 888ooo88P' 888 888 `"Y8888o. $$$$P""Y$$$Y""W$$$$$ - $$$$ p"LFG"q $$$$$ 888 888 888 `"Y88b $$$$ p"LFG"q $$$$$ - $$$$ .$$$$$. $$$$ 888 `88b d88'oo .d8P $$$$ .$$$$$. $$$$ - $$DcaU$$$$$$$$$$ o888o `Y8bood8P' 8""88888P' $$DcaU$$$$$$$$$$ - "Y$$$"*"$$$Y" "Y$$$"*"$$$Y" - "$b.$$" "$b.$$" - - .o. . o8o . .o8 - .888. .o8 `"' .o8 "888 - .8"888. .ooooo. .o888oooooo oooo ooo .oooo. .o888oo .ooooo. .oooo888 - .8' `888. d88' `"Y8 888 `888 `88. .8' `P )88b 888 d88' `88bd88' `888 - .88ooo8888. 888 888 888 `88..8' .oP"888 888 888ooo888888 888 - .8' `888. 888 .o8 888 . 888 `888' d8( 888 888 .888 .o888 888 - o88o o8888o`Y8bod8P' "888"o888o `8' `Y888""8o "888"`Y8bod8P'`Y8bod88P" - -"#; - /// Maximum block slot number. Block with slots bigger than this constant will NOT be processed. const MAXIMUM_BLOCK_SLOT_NUMBER: u64 = 4_294_967_296; // 2^32 @@ -335,6 +311,15 @@ pub enum BlockError { max_blobs_at_epoch: usize, block: usize, }, + /// The bid's parent_block_root does not match the block's parent_root. + /// + /// ## Peer scoring + /// + /// The block is invalid and the peer should be penalized. + BidParentRootMismatch { + bid_parent_root: Hash256, + block_parent_root: Hash256, + }, } /// Which specific signature(s) are invalid in a SignedBeaconBlock @@ -382,13 +367,6 @@ pub enum ExecutionPayloadError { /// /// The block is invalid and the peer is faulty InvalidPayloadTimestamp { expected: u64, found: u64 }, - /// The execution payload references an execution block that cannot trigger the merge. - /// - /// ## Peer scoring - /// - /// The block is invalid and the peer sent us a block that passes gossip propagation conditions, - /// but is invalid upon further verification. - InvalidTerminalPoWBlock { parent_hash: ExecutionBlockHash }, /// The `TERMINAL_BLOCK_HASH` is set, but the block has not reached the /// `TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH`. /// @@ -400,16 +378,6 @@ pub enum ExecutionPayloadError { activation_epoch: Epoch, epoch: Epoch, }, - /// The `TERMINAL_BLOCK_HASH` is set, but does not match the value specified by the block. - /// - /// ## Peer scoring - /// - /// The block is invalid and the peer sent us a block that passes gossip propagation conditions, - /// but is invalid upon further verification. - InvalidTerminalBlockHash { - terminal_block_hash: ExecutionBlockHash, - payload_parent_hash: ExecutionBlockHash, - }, /// The execution node is syncing but we fail the conditions for optimistic sync /// /// ## Peer scoring @@ -434,16 +402,11 @@ impl ExecutionPayloadError { // This is a trivial gossip validation condition, there is no reason for an honest peer // to propagate a block with an invalid payload time stamp. ExecutionPayloadError::InvalidPayloadTimestamp { .. } => true, - // An honest optimistic node may propagate blocks with an invalid terminal PoW block, we - // should not penalized them. - ExecutionPayloadError::InvalidTerminalPoWBlock { .. } => false, // This condition is checked *after* gossip propagation, therefore penalizing gossip // peers for this block would be unfair. There may be an argument to penalize RPC // blocks, since even an optimistic node shouldn't verify this block. We will remove the // penalties for all block imports to keep things simple. ExecutionPayloadError::InvalidActivationEpoch { .. } => false, - // As per `Self::InvalidActivationEpoch`. - ExecutionPayloadError::InvalidTerminalBlockHash { .. } => false, // Do not penalize the peer since it's not their fault that *we're* optimistic. ExecutionPayloadError::UnverifiedNonOptimisticCandidate => false, } @@ -527,7 +490,6 @@ impl From<ArithError> for BlockError { #[derive(Debug, PartialEq, Clone, Encode, Decode)] pub struct PayloadVerificationOutcome { pub payload_verification_status: PayloadVerificationStatus, - pub is_valid_merge_transition_block: bool, } /// Information about invalid blocks which might still be slashable despite being invalid. @@ -622,7 +584,7 @@ pub(crate) fn process_block_slash_info<T: BeaconChainTypes, TErr: BlockBlobError /// will be returned. #[instrument(skip_all)] pub fn signature_verify_chain_segment<T: BeaconChainTypes>( - mut chain_segment: Vec<(Hash256, RpcBlock<T::EthSpec>)>, + mut chain_segment: Vec<(Hash256, RangeSyncBlock<T::EthSpec>)>, chain: &BeaconChain<T>, ) -> Result<Vec<SignatureVerifiedBlock<T>>, BlockError> { if chain_segment.is_empty() { @@ -646,26 +608,26 @@ pub fn signature_verify_chain_segment<T: BeaconChainTypes>( &chain.spec, )?; - // unzip chain segment and verify kzg in bulk - let (roots, blocks): (Vec<_>, Vec<_>) = chain_segment.into_iter().unzip(); - let maybe_available_blocks = chain + let mut available_blocks = Vec::with_capacity(chain_segment.len()); + let mut signature_verified_blocks = Vec::with_capacity(chain_segment.len()); + + for (block_root, block) in chain_segment { + let consensus_context = + ConsensusContext::new(block.slot()).set_current_block_root(block_root); + + let available_block = block.into_available_block(); + available_blocks.push(available_block.clone()); + signature_verified_blocks.push(SignatureVerifiedBlock { + block: MaybeAvailableBlock::Available(available_block), + block_root, + parent: None, + consensus_context, + }); + } + + chain .data_availability_checker - .verify_kzg_for_rpc_blocks(blocks)?; - // zip it back up - let mut signature_verified_blocks = roots - .into_iter() - .zip(maybe_available_blocks) - .map(|(block_root, maybe_available_block)| { - let consensus_context = ConsensusContext::new(maybe_available_block.slot()) - .set_current_block_root(block_root); - SignatureVerifiedBlock { - block: maybe_available_block, - block_root, - parent: None, - consensus_context, - } - }) - .collect::<Vec<_>>(); + .batch_verify_kzg_for_available_blocks(&available_blocks)?; // verify signatures let pubkey_cache = get_validator_pubkey_cache(chain)?; @@ -709,7 +671,8 @@ pub struct SignatureVerifiedBlock<T: BeaconChainTypes> { } /// Used to await the result of executing payload with an EE. -type PayloadVerificationHandle = JoinHandle<Option<Result<PayloadVerificationOutcome, BlockError>>>; +pub type PayloadVerificationHandle = + JoinHandle<Option<Result<PayloadVerificationOutcome, BlockError>>>; /// A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and /// ready to import into the `BeaconChain`. The validation includes: @@ -878,15 +841,15 @@ impl<T: BeaconChainTypes> GossipVerifiedBlock<T> { // Do not gossip blocks that claim to contain more blobs than the max allowed // at the given block epoch. - if let Ok(commitments) = block.message().body().blob_kzg_commitments() { + if let Some(blob_kzg_commitments_len) = block.message().blob_kzg_commitments_len() { let max_blobs_at_epoch = chain .spec .max_blobs_per_block(block.slot().epoch(T::EthSpec::slots_per_epoch())) as usize; - if commitments.len() > max_blobs_at_epoch { + if blob_kzg_commitments_len > max_blobs_at_epoch { return Err(BlockError::InvalidBlobCount { max_blobs_at_epoch, - block: commitments.len(), + block: blob_kzg_commitments_len, }); } } @@ -923,6 +886,24 @@ impl<T: BeaconChainTypes> GossipVerifiedBlock<T> { let block_epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); let (parent_block, block) = verify_parent_block_is_known::<T>(&fork_choice_read_lock, block)?; + + // [New in Gloas]: Verify bid.parent_block_root matches block.parent_root. + if let Ok(bid) = block.message().body().signed_execution_payload_bid() + && bid.message.parent_block_root != block.message().parent_root() + { + return Err(BlockError::BidParentRootMismatch { + bid_parent_root: bid.message.parent_block_root, + block_parent_root: block.message().parent_root(), + }); + } + + // TODO(gloas) The following validation can only be completed once fork choice has been implemented: + // The block's parent execution payload (defined by bid.parent_block_hash) has been seen + // (via gossip or non-gossip sources) (a client MAY queue blocks for processing + // once the parent payload is retrieved). If execution_payload verification of block's execution + // payload parent by an execution node is complete, verify the block's execution payload + // parent (defined by bid.parent_block_hash) passes all validation. + drop(fork_choice_read_lock); // Track the number of skip slots between the block and its parent. @@ -1029,8 +1010,15 @@ impl<T: BeaconChainTypes> GossipVerifiedBlock<T> { }); } - // Validate the block's execution_payload (if any). - validate_execution_payload_for_gossip(&parent_block, block.message(), chain)?; + // [New in Gloas]: Skip payload validation checks. The payload now arrives separately + // via `ExecutionPayloadEnvelope`. + if !chain + .spec + .fork_name_at_slot::<T::EthSpec>(block.slot()) + .gloas_enabled() + { + validate_execution_payload_for_gossip(&parent_block, block.message(), chain)?; + } // Beacon API block_gossip events if let Some(event_handler) = chain.event_handler.as_ref() @@ -1202,15 +1190,35 @@ impl<T: BeaconChainTypes> SignatureVerifiedBlock<T> { let result = info_span!("signature_verify").in_scope(|| signature_verifier.verify()); match result { - Ok(_) => Ok(Self { - block: MaybeAvailableBlock::AvailabilityPending { + Ok(_) => { + // gloas blocks are always available. + let maybe_available = if chain + .spec + .fork_name_at_slot::<T::EthSpec>(block.slot()) + .gloas_enabled() + { + MaybeAvailableBlock::Available( + AvailableBlock::new( + block, + AvailableBlockData::NoData, + &chain.data_availability_checker, + chain.spec.clone(), + ) + .map_err(BlockError::AvailabilityCheck)?, + ) + } else { + MaybeAvailableBlock::AvailabilityPending { + block_root: from.block_root, + block, + } + }; + Ok(Self { + block: maybe_available, block_root: from.block_root, - block, - }, - block_root: from.block_root, - parent: Some(parent), - consensus_context, - }), + parent: Some(parent), + consensus_context, + }) + } Err(_) => Err(BlockError::InvalidSignature( InvalidSignature::BlockBodySignatures, )), @@ -1281,11 +1289,11 @@ impl<T: BeaconChainTypes> IntoExecutionPendingBlock<T> for SignatureVerifiedBloc } } -impl<T: BeaconChainTypes> IntoExecutionPendingBlock<T> for RpcBlock<T::EthSpec> { +impl<T: BeaconChainTypes> IntoExecutionPendingBlock<T> for RangeSyncBlock<T::EthSpec> { /// Verifies the `SignedBeaconBlock` by first transforming it into a `SignatureVerifiedBlock` /// and then using that implementation of `IntoExecutionPendingBlock` to complete verification. #[instrument( - name = "rpc_block_into_execution_pending_block_slashable", + name = "range_sync_block_into_execution_pending_block_slashable", level = "debug" skip_all, )] @@ -1298,16 +1306,55 @@ impl<T: BeaconChainTypes> IntoExecutionPendingBlock<T> for RpcBlock<T::EthSpec> // Perform an early check to prevent wasting time on irrelevant blocks. let block_root = check_block_relevancy(self.as_block(), block_root, chain) .map_err(|e| BlockSlashInfo::SignatureNotChecked(self.signed_block_header(), e))?; - let maybe_available = chain + + let available_block = self.into_available_block(); + chain .data_availability_checker - .verify_kzg_for_rpc_block(self.clone()) + .verify_kzg_for_available_block(&available_block) .map_err(|e| { BlockSlashInfo::SignatureNotChecked( - self.signed_block_header(), + available_block.as_block().signed_block_header(), BlockError::AvailabilityCheck(e), ) })?; - SignatureVerifiedBlock::check_slashable(maybe_available, block_root, chain)? + let maybe_available_block = MaybeAvailableBlock::Available(available_block); + SignatureVerifiedBlock::check_slashable(maybe_available_block, block_root, chain)? + .into_execution_pending_block_slashable(block_root, chain, notify_execution_layer) + } + + fn block(&self) -> &SignedBeaconBlock<T::EthSpec> { + self.as_block() + } + + fn block_cloned(&self) -> Arc<SignedBeaconBlock<T::EthSpec>> { + self.block_cloned() + } +} + +impl<T: BeaconChainTypes> IntoExecutionPendingBlock<T> for LookupBlock<T::EthSpec> { + /// Verifies the `SignedBeaconBlock` by first transforming it into a `SignatureVerifiedBlock` + /// and then using that implementation of `IntoExecutionPendingBlock` to complete verification. + #[instrument( + name = "lookup_block_into_execution_pending_block_slashable", + level = "debug" + skip_all, + )] + fn into_execution_pending_block_slashable( + self, + block_root: Hash256, + chain: &Arc<BeaconChain<T>>, + notify_execution_layer: NotifyExecutionLayer, + ) -> Result<ExecutionPendingBlock<T>, BlockSlashInfo<BlockError>> { + // Perform an early check to prevent wasting time on irrelevant blocks. + let block_root = check_block_relevancy(self.as_block(), block_root, chain) + .map_err(|e| BlockSlashInfo::SignatureNotChecked(self.signed_block_header(), e))?; + + let maybe_available_block = MaybeAvailableBlock::AvailabilityPending { + block_root, + block: self.block_cloned(), + }; + + SignatureVerifiedBlock::check_slashable(maybe_available_block, block_root, chain)? .into_execution_pending_block_slashable(block_root, chain, notify_execution_layer) } @@ -1328,7 +1375,7 @@ impl<T: BeaconChainTypes> ExecutionPendingBlock<T> { /// verification must be done upstream (e.g., via a `SignatureVerifiedBlock` /// /// Returns an error if the block is invalid, or if the block was unable to be verified. - #[instrument(skip_all, level = "debug")] + #[instrument(skip_all, level = "debug", fields(?block_root))] pub fn from_signature_verified_components( block: MaybeAvailableBlock<T::EthSpec>, block_root: Hash256, @@ -1392,27 +1439,10 @@ impl<T: BeaconChainTypes> ExecutionPendingBlock<T> { &parent.pre_state, notify_execution_layer, )?; - let is_valid_merge_transition_block = - is_merge_transition_block(&parent.pre_state, block.message().body()); - let payload_verification_future = async move { let chain = payload_notifier.chain.clone(); let block = payload_notifier.block.clone(); - // If this block triggers the merge, check to ensure that it references valid execution - // blocks. - // - // The specification defines this check inside `on_block` in the fork-choice specification, - // however we perform the check here for two reasons: - // - // - There's no point in importing a block that will fail fork choice, so it's best to fail - // early. - // - Doing the check here means we can keep our fork-choice implementation "pure". I.e., no - // calls to remote servers. - if is_valid_merge_transition_block { - validate_merge_block(&chain, block.message(), AllowOptimisticImport::Yes).await?; - }; - // The specification declares that this should be run *inside* `per_block_processing`, // however we run it here to keep `per_block_processing` pure (i.e., no calls to external // servers). @@ -1427,7 +1457,6 @@ impl<T: BeaconChainTypes> ExecutionPendingBlock<T> { Ok(PayloadVerificationOutcome { payload_verification_status, - is_valid_merge_transition_block, }) }; // Spawn the payload verification future as a new task, but don't wait for it to complete. @@ -1560,24 +1589,6 @@ impl<T: BeaconChainTypes> ExecutionPendingBlock<T> { metrics::stop_timer(committee_timer); - /* - * If we have block reward listeners, compute the block reward and push it to the - * event handler. - */ - if let Some(ref event_handler) = chain.event_handler - && event_handler.has_block_reward_subscribers() - { - let mut reward_cache = Default::default(); - let block_reward = chain.compute_block_reward( - block.message(), - block_root, - &state, - &mut reward_cache, - true, - )?; - event_handler.register(EventKind::BlockReward(block_reward)); - } - /* * Perform `per_block_processing` on the block and state, returning early if the block is * invalid. @@ -1654,6 +1665,7 @@ impl<T: BeaconChainTypes> ExecutionPendingBlock<T> { current_slot, indexed_attestation, AttestationFromBlock::True, + &chain.spec, ) { Ok(()) => Ok(()), // Ignore invalid attestations whilst importing attestations from a block. The @@ -1662,6 +1674,31 @@ impl<T: BeaconChainTypes> ExecutionPendingBlock<T> { Err(e) => Err(BlockError::BeaconChainError(Box::new(e.into()))), }?; } + + // Register each payload attestation in the block with fork choice. + if let Ok(payload_attestations) = block.message().body().payload_attestations() { + for (i, payload_attestation) in payload_attestations.iter().enumerate() { + let indexed_payload_attestation = consensus_context + .get_indexed_payload_attestation(&state, payload_attestation, &chain.spec) + .map_err(|e| BlockError::PerBlockProcessingError(e.into_with_index(i)))?; + + let ptc = state + .get_ptc(indexed_payload_attestation.data.slot, &chain.spec) + .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; + + // Ignore invalid payload attestations from blocks (same as + // regular attestations — the block may be old). + if let Err(e) = fork_choice.on_payload_attestation( + current_slot, + indexed_payload_attestation, + AttestationFromBlock::True, + &ptc.0, + ) && !matches!(e, ForkChoiceError::InvalidPayloadAttestation(_)) + { + return Err(BlockError::BeaconChainError(Box::new(e.into()))); + } + } + } drop(fork_choice); Ok(Self { @@ -1777,10 +1814,12 @@ pub fn check_block_relevancy<T: BeaconChainTypes>( ) -> Result<Hash256, BlockError> { let block = signed_block.message(); + let present_slot = chain.slot()?; + // Do not process blocks from the future. - if block.slot() > chain.slot()? { + if block.slot() > present_slot { return Err(BlockError::FutureSlot { - present_slot: chain.slot()?, + present_slot, block_slot: block.slot(), }); } @@ -1912,6 +1951,7 @@ fn load_parent<T: BeaconChainTypes, B: AsBlock<T::EthSpec>>( // Retrieve any state that is advanced through to at most `block.slot()`: this is // particularly important if `block` descends from the finalized/split block, but at a slot // prior to the finalized slot (which is invalid and inaccessible in our DB schema). + // let (parent_state_root, state) = chain .store .get_advanced_hot_state(root, block.slot(), parent_block.state_root())? @@ -2127,11 +2167,13 @@ pub fn verify_header_signature<T: BeaconChainTypes, Err: BlockBlobError>( .get(header.message.proposer_index as usize) .cloned() .ok_or(Err::unknown_validator_error(header.message.proposer_index))?; - let head_fork = chain.canonical_head.cached_head().head_fork(); + let fork = chain + .spec + .fork_at_epoch(header.message.slot.epoch(T::EthSpec::slots_per_epoch())); if header.verify_signature::<T::EthSpec>( &proposer_pubkey, - &head_fork, + &fork, chain.genesis_validators_root, &chain.spec, ) { diff --git a/beacon_node/beacon_chain/src/block_verification_types.rs b/beacon_node/beacon_chain/src/block_verification_types.rs index 5978e97c4d..be73ef15d7 100644 --- a/beacon_node/beacon_chain/src/block_verification_types.rs +++ b/beacon_node/beacon_chain/src/block_verification_types.rs @@ -1,206 +1,126 @@ -use crate::data_availability_checker::AvailabilityCheckError; -pub use crate::data_availability_checker::{AvailableBlock, MaybeAvailableBlock}; -use crate::data_column_verification::{CustodyDataColumn, CustodyDataColumnList}; -use crate::{PayloadVerificationOutcome, get_block_root}; +use crate::data_availability_checker::{AvailabilityCheckError, DataAvailabilityChecker}; +pub use crate::data_availability_checker::{ + AvailableBlock, AvailableBlockData, MaybeAvailableBlock, +}; +use crate::{BeaconChainTypes, PayloadVerificationOutcome}; use educe::Educe; -use ssz_types::VariableList; use state_processing::ConsensusContext; use std::fmt::{Debug, Formatter}; use std::sync::Arc; -use types::blob_sidecar::BlobIdentifier; +use types::data::BlobIdentifier; use types::{ - BeaconBlockRef, BeaconState, BlindedPayload, BlobSidecarList, Epoch, EthSpec, Hash256, + BeaconBlockRef, BeaconState, BlindedPayload, ChainSpec, Epoch, EthSpec, Hash256, SignedBeaconBlock, SignedBeaconBlockHeader, Slot, }; -/// A block that has been received over RPC. It has 2 internal variants: -/// -/// 1. `BlockAndBlobs`: A fully available post deneb block with all the blobs available. This variant -/// is only constructed after making consistency checks between blocks and blobs. -/// Hence, it is fully self contained w.r.t verification. i.e. this block has all the required -/// data to get verified and imported into fork choice. -/// -/// 2. `Block`: This can be a fully available pre-deneb block **or** a post-deneb block that may or may -/// not require blobs to be considered fully available. -/// -/// Note: We make a distinction over blocks received over gossip because -/// in a post-deneb world, the blobs corresponding to a given block that are received -/// over rpc do not contain the proposer signature for dos resistance. -#[derive(Clone, Educe)] -#[educe(Hash(bound(E: EthSpec)))] -pub struct RpcBlock<E: EthSpec> { +/// A wrapper around a `SignedBeaconBlock`. This varaint is constructed +/// when lookup sync only fetches a single block. It does not contain +/// any blobs or data columns. +pub struct LookupBlock<E: EthSpec> { + block: Arc<SignedBeaconBlock<E>>, block_root: Hash256, - block: RpcBlockInner<E>, } -impl<E: EthSpec> Debug for RpcBlock<E> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RpcBlock({:?})", self.block_root) +impl<E: EthSpec> LookupBlock<E> { + pub fn new(block: Arc<SignedBeaconBlock<E>>) -> Self { + let block_root = block.canonical_root(); + Self { block, block_root } + } + + pub fn block(&self) -> &SignedBeaconBlock<E> { + &self.block } -} -impl<E: EthSpec> RpcBlock<E> { pub fn block_root(&self) -> Hash256 { self.block_root } + pub fn block_cloned(&self) -> Arc<SignedBeaconBlock<E>> { + self.block.clone() + } +} + +/// A fully available block that has been constructed by range sync. +/// The block contains all the data required to import into fork choice. +/// This includes any and all blobs/columns required, including zero if +/// none are required. This can happen if the block is pre-deneb or if +/// it's simply past the DA boundary. +#[derive(Clone, Educe)] +#[educe(Hash(bound(E: EthSpec)))] +pub struct RangeSyncBlock<E: EthSpec> { + block: AvailableBlock<E>, +} + +impl<E: EthSpec> Debug for RangeSyncBlock<E> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RpcBlock({:?})", self.block_root()) + } +} + +impl<E: EthSpec> RangeSyncBlock<E> { + pub fn block_root(&self) -> Hash256 { + self.block.block_root() + } + pub fn as_block(&self) -> &SignedBeaconBlock<E> { - match &self.block { - RpcBlockInner::Block(block) => block, - RpcBlockInner::BlockAndBlobs(block, _) => block, - RpcBlockInner::BlockAndCustodyColumns(block, _) => block, - } + self.block.block() } pub fn block_cloned(&self) -> Arc<SignedBeaconBlock<E>> { - match &self.block { - RpcBlockInner::Block(block) => block.clone(), - RpcBlockInner::BlockAndBlobs(block, _) => block.clone(), - RpcBlockInner::BlockAndCustodyColumns(block, _) => block.clone(), - } + self.block.block_cloned() } - pub fn blobs(&self) -> Option<&BlobSidecarList<E>> { - match &self.block { - RpcBlockInner::Block(_) => None, - RpcBlockInner::BlockAndBlobs(_, blobs) => Some(blobs), - RpcBlockInner::BlockAndCustodyColumns(_, _) => None, - } - } - - pub fn custody_columns(&self) -> Option<&CustodyDataColumnList<E>> { - match &self.block { - RpcBlockInner::Block(_) => None, - RpcBlockInner::BlockAndBlobs(_, _) => None, - RpcBlockInner::BlockAndCustodyColumns(_, data_columns) => Some(data_columns), - } + pub fn block_data(&self) -> &AvailableBlockData<E> { + self.block.data() } } -/// Note: This variant is intentionally private because we want to safely construct the -/// internal variants after applying consistency checks to ensure that the block and blobs -/// are consistent with respect to each other. -#[derive(Debug, Clone, Educe)] -#[educe(Hash(bound(E: EthSpec)))] -enum RpcBlockInner<E: EthSpec> { - /// Single block lookup response. This should potentially hit the data availability cache. - Block(Arc<SignedBeaconBlock<E>>), - /// This variant is used with parent lookups and by-range responses. It should have all blobs - /// ordered, all block roots matching, and the correct number of blobs for this block. - BlockAndBlobs(Arc<SignedBeaconBlock<E>>, BlobSidecarList<E>), - /// This variant is used with parent lookups and by-range responses. It should have all - /// requested data columns, all block roots matching for this block. - BlockAndCustodyColumns(Arc<SignedBeaconBlock<E>>, CustodyDataColumnList<E>), -} - -impl<E: EthSpec> RpcBlock<E> { - /// Constructs a `Block` variant. - pub fn new_without_blobs( - block_root: Option<Hash256>, +impl<E: EthSpec> RangeSyncBlock<E> { + /// Constructs an `RangeSyncBlock` from a block and availability data. + /// + /// # Errors + /// + /// Returns `AvailabilityCheckError` if: + /// - `InvalidAvailableBlockData`: Block data is provided but not required. + /// - `MissingBlobs`: Block requires blobs but they are missing or incomplete. + /// - `MissingCustodyColumns`: Block requires custody columns but they are incomplete. + pub fn new<T>( block: Arc<SignedBeaconBlock<E>>, - ) -> Self { - let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); - - Self { - block_root, - block: RpcBlockInner::Block(block), - } - } - - /// Constructs a new `BlockAndBlobs` variant after making consistency - /// checks between the provided blocks and blobs. This struct makes no - /// guarantees about whether blobs should be present, only that they are - /// consistent with the block. An empty list passed in for `blobs` is - /// viewed the same as `None` passed in. - pub fn new( - block_root: Option<Hash256>, - block: Arc<SignedBeaconBlock<E>>, - blobs: Option<BlobSidecarList<E>>, - ) -> Result<Self, AvailabilityCheckError> { - let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); - // Treat empty blob lists as if they are missing. - let blobs = blobs.filter(|b| !b.is_empty()); - - if let (Some(blobs), Ok(block_commitments)) = ( - blobs.as_ref(), - block.message().body().blob_kzg_commitments(), - ) { - if blobs.len() != block_commitments.len() { - return Err(AvailabilityCheckError::MissingBlobs); - } - for (blob, &block_commitment) in blobs.iter().zip(block_commitments.iter()) { - let blob_commitment = blob.kzg_commitment; - if blob_commitment != block_commitment { - return Err(AvailabilityCheckError::KzgCommitmentMismatch { - block_commitment, - blob_commitment, - }); - } - } - } - let inner = match blobs { - Some(blobs) => RpcBlockInner::BlockAndBlobs(block, blobs), - None => RpcBlockInner::Block(block), - }; + block_data: AvailableBlockData<E>, + da_checker: &DataAvailabilityChecker<T>, + spec: Arc<ChainSpec>, + ) -> Result<Self, AvailabilityCheckError> + where + T: BeaconChainTypes<EthSpec = E>, + { + let available_block = AvailableBlock::new(block, block_data, da_checker, spec)?; Ok(Self { - block_root, - block: inner, - }) - } - - pub fn new_with_custody_columns( - block_root: Option<Hash256>, - block: Arc<SignedBeaconBlock<E>>, - custody_columns: Vec<CustodyDataColumn<E>>, - ) -> Result<Self, AvailabilityCheckError> { - let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); - - if block.num_expected_blobs() > 0 && custody_columns.is_empty() { - // The number of required custody columns is out of scope here. - return Err(AvailabilityCheckError::MissingCustodyColumns); - } - // Treat empty data column lists as if they are missing. - let inner = if !custody_columns.is_empty() { - RpcBlockInner::BlockAndCustodyColumns(block, VariableList::new(custody_columns)?) - } else { - RpcBlockInner::Block(block) - }; - Ok(Self { - block_root, - block: inner, + block: available_block, }) } #[allow(clippy::type_complexity)] - pub fn deconstruct( - self, - ) -> ( - Hash256, - Arc<SignedBeaconBlock<E>>, - Option<BlobSidecarList<E>>, - Option<CustodyDataColumnList<E>>, - ) { - let block_root = self.block_root(); - match self.block { - RpcBlockInner::Block(block) => (block_root, block, None, None), - RpcBlockInner::BlockAndBlobs(block, blobs) => (block_root, block, Some(blobs), None), - RpcBlockInner::BlockAndCustodyColumns(block, data_columns) => { - (block_root, block, None, Some(data_columns)) - } - } + pub fn deconstruct(self) -> (Hash256, Arc<SignedBeaconBlock<E>>, AvailableBlockData<E>) { + self.block.deconstruct() } + pub fn n_blobs(&self) -> usize { - match &self.block { - RpcBlockInner::Block(_) | RpcBlockInner::BlockAndCustodyColumns(_, _) => 0, - RpcBlockInner::BlockAndBlobs(_, blobs) => blobs.len(), + match self.block_data() { + AvailableBlockData::NoData | AvailableBlockData::DataColumns(_) => 0, + AvailableBlockData::Blobs(blobs) => blobs.len(), } } + pub fn n_data_columns(&self) -> usize { - match &self.block { - RpcBlockInner::Block(_) | RpcBlockInner::BlockAndBlobs(_, _) => 0, - RpcBlockInner::BlockAndCustodyColumns(_, data_columns) => data_columns.len(), + match self.block_data() { + AvailableBlockData::NoData | AvailableBlockData::Blobs(_) => 0, + AvailableBlockData::DataColumns(columns) => columns.len(), } } + + pub fn into_available_block(self) -> AvailableBlock<E> { + self.block + } } /// A block that has gone through all pre-deneb block processing checks including block processing @@ -332,7 +252,7 @@ impl<E: EthSpec> AvailabilityPendingExecutedBlock<E> { } } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct BlockImportData<E: EthSpec> { pub block_root: Hash256, pub state: BeaconState<E>, @@ -340,21 +260,6 @@ pub struct BlockImportData<E: EthSpec> { pub consensus_context: ConsensusContext<E>, } -impl<E: EthSpec> BlockImportData<E> { - pub fn __new_for_test( - block_root: Hash256, - state: BeaconState<E>, - parent_block: SignedBeaconBlock<E, BlindedPayload<E>>, - ) -> Self { - Self { - block_root, - state, - parent_block, - consensus_context: ConsensusContext::new(Slot::new(0)), - } - } -} - /// Trait for common block operations. pub trait AsBlock<E: EthSpec> { fn slot(&self) -> Slot; @@ -480,7 +385,7 @@ impl<E: EthSpec> AsBlock<E> for AvailableBlock<E> { } } -impl<E: EthSpec> AsBlock<E> for RpcBlock<E> { +impl<E: EthSpec> AsBlock<E> for RangeSyncBlock<E> { fn slot(&self) -> Slot { self.as_block().slot() } @@ -500,20 +405,42 @@ impl<E: EthSpec> AsBlock<E> for RpcBlock<E> { self.as_block().message() } fn as_block(&self) -> &SignedBeaconBlock<E> { - match &self.block { - RpcBlockInner::Block(block) => block, - RpcBlockInner::BlockAndBlobs(block, _) => block, - RpcBlockInner::BlockAndCustodyColumns(block, _) => block, - } + self.block.as_block() } fn block_cloned(&self) -> Arc<SignedBeaconBlock<E>> { - match &self.block { - RpcBlockInner::Block(block) => block.clone(), - RpcBlockInner::BlockAndBlobs(block, _) => block.clone(), - RpcBlockInner::BlockAndCustodyColumns(block, _) => block.clone(), - } + self.block.block_cloned() } fn canonical_root(&self) -> Hash256 { - self.as_block().canonical_root() + self.block.block_root() + } +} + +impl<E: EthSpec> AsBlock<E> for LookupBlock<E> { + fn slot(&self) -> Slot { + self.block().slot() + } + fn epoch(&self) -> Epoch { + self.block().epoch() + } + fn parent_root(&self) -> Hash256 { + self.block().parent_root() + } + fn state_root(&self) -> Hash256 { + self.block().state_root() + } + fn signed_block_header(&self) -> SignedBeaconBlockHeader { + self.block().signed_block_header() + } + fn message(&self) -> BeaconBlockRef<'_, E> { + self.block().message() + } + fn as_block(&self) -> &SignedBeaconBlock<E> { + self.block() + } + fn block_cloned(&self) -> Arc<SignedBeaconBlock<E>> { + self.block_cloned() + } + fn canonical_root(&self) -> Hash256 { + self.block_root } } diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 7a55499a1b..01dcaa74a9 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -7,9 +7,8 @@ use crate::beacon_proposer_cache::BeaconProposerCache; use crate::custody_context::NodeCustodyType; use crate::data_availability_checker::DataAvailabilityChecker; use crate::fork_choice_signal::ForkChoiceSignalTx; -use crate::fork_revert::{reset_fork_choice_to_finalization, revert_to_fork_boundary}; use crate::graffiti_calculator::{GraffitiCalculator, GraffitiOrigin}; -use crate::kzg_utils::build_data_column_sidecars; +use crate::kzg_utils::{build_data_column_sidecars_fulu, build_data_column_sidecars_gloas}; use crate::light_client_server_cache::LightClientServerCache; use crate::migrate::{BackgroundMigrator, MigratorConfig}; use crate::observed_data_sidecars::ObservedDataSidecars; @@ -24,7 +23,7 @@ use crate::{ use bls::Signature; use execution_layer::ExecutionLayer; use fixed_bytes::FixedBytesExtended; -use fork_choice::{ForkChoice, ResetPayloadStatuses}; +use fork_choice::{ForkChoice, PayloadStatus, ResetPayloadStatuses}; use futures::channel::mpsc::Sender; use kzg::Kzg; use logging::crit; @@ -35,17 +34,20 @@ use rand::RngCore; use rayon::prelude::*; use slasher::Slasher; use slot_clock::{SlotClock, TestingSlotClock}; -use state_processing::{AllCaches, per_slot_processing}; +use state_processing::AllCaches; +use state_processing::genesis::genesis_block; +use state_processing::per_slot_processing; use std::marker::PhantomData; use std::sync::Arc; use std::time::Duration; use store::{Error as StoreError, HotColdDB, ItemStore, KeyValueStoreOp}; use task_executor::{ShutdownReason, TaskExecutor}; -use tracing::{debug, error, info}; -use types::data_column_custody_group::CustodyIndex; +use tracing::{debug, error, info, warn}; +use tree_hash::TreeHash; +use types::data::CustodyIndex; use types::{ - BeaconBlock, BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, - Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, + BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, Epoch, EthSpec, + Hash256, SignedBeaconBlock, Slot, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -321,7 +323,7 @@ where .clone() .ok_or("set_genesis_state requires a store")?; - let beacon_block = genesis_block(&mut beacon_state, &self.spec)?; + let beacon_block = make_genesis_block(&mut beacon_state, &self.spec)?; beacon_state .build_caches(&self.spec) @@ -358,6 +360,7 @@ where Ok(( BeaconSnapshot { beacon_block_root, + execution_envelope: None, beacon_block: Arc::new(beacon_block), beacon_state, }, @@ -371,9 +374,9 @@ where // Initialize anchor info before attempting to write the genesis state. // Since v4.4.0 we will set the anchor with a dummy state upper limit in order to prevent - // historic states from being retained (unless `--reconstruct-historic-states` is set). - let retain_historic_states = self.chain_config.reconstruct_historic_states; - let genesis_beacon_block = genesis_block(&mut beacon_state, &self.spec)?; + // historic states from being retained (unless `--archive` is set). + let retain_historic_states = self.chain_config.archive; + let genesis_beacon_block = make_genesis_block(&mut beacon_state, &self.spec)?; self.pending_io_batch.push( store .init_anchor_info( @@ -528,7 +531,7 @@ where // case it will be stored in the hot DB. In this case, we need to ensure the store's anchor // is initialised prior to storing the state, as the anchor is required for working out // hdiff storage strategies. - let retain_historic_states = self.chain_config.reconstruct_historic_states; + let retain_historic_states = self.chain_config.archive; self.pending_io_batch.push( store .init_anchor_info( @@ -618,6 +621,7 @@ where let snapshot = BeaconSnapshot { beacon_block_root: weak_subj_block_root, + execution_envelope: None, beacon_block: Arc::new(weak_subj_block), beacon_state: weak_subj_state, }; @@ -773,57 +777,36 @@ where slot_clock.now().ok_or("Unable to read slot")? }; - let initial_head_block_root = fork_choice + let (initial_head_block_root, head_payload_status) = fork_choice .get_head(current_slot, &self.spec) .map_err(|e| format!("Unable to get fork choice head: {:?}", e))?; - // Try to decode the head block according to the current fork, if that fails, try - // to backtrack to before the most recent fork. - let (head_block_root, head_block, head_reverted) = - match store.get_full_block(&initial_head_block_root) { - Ok(Some(block)) => (initial_head_block_root, block, false), - Ok(None) => return Err("Head block not found in store".into()), - Err(StoreError::SszDecodeError(_)) => { - error!( - message = "This node has likely missed a hard fork. \ - It will try to revert the invalid blocks and keep running, \ - but any stray blocks and states will not be deleted. \ - Long-term you should consider re-syncing this node.", - "Error decoding head block" - ); - let (block_root, block) = revert_to_fork_boundary( - current_slot, - initial_head_block_root, - store.clone(), - &self.spec, - )?; - - (block_root, block, true) - } - Err(e) => return Err(descriptive_db_error("head block", &e)), - }; + let head_block_root = initial_head_block_root; + let head_block = store + .get_full_block(&initial_head_block_root) + .map_err(|e| descriptive_db_error("head block", &e))? + .ok_or("Head block not found in store")?; let (_head_state_root, head_state) = store .get_advanced_hot_state(head_block_root, current_slot, head_block.state_root()) .map_err(|e| descriptive_db_error("head state", &e))? .ok_or("Head state not found in store")?; - // If the head reverted then we need to reset fork choice using the new head's finalized - // checkpoint. - if head_reverted { - fork_choice = reset_fork_choice_to_finalization( - head_block_root, - &head_state, - store.clone(), - Some(current_slot), - &self.spec, - )?; - } - let head_shuffling_ids = BlockShufflingIds::try_from_head(head_block_root, &head_state)?; + // Load the execution envelope from the store if the head has a Full payload. + let execution_envelope = if head_payload_status == PayloadStatus::Full { + store + .get_payload_envelope(&head_block_root) + .map_err(|e| format!("Error loading head execution envelope: {:?}", e))? + .map(Arc::new) + } else { + None + }; + let mut head_snapshot = BeaconSnapshot { beacon_block_root: head_block_root, + execution_envelope, beacon_block: Arc::new(head_block), beacon_state: head_state, }; @@ -847,6 +830,33 @@ where )); } + // Check if the head snapshot is within the weak subjectivity period + let head_state = &head_snapshot.beacon_state; + let Ok(ws_period) = head_state.compute_weak_subjectivity_period(&self.spec) else { + return Err(format!( + "Unable to compute the weak subjectivity period at the head snapshot slot: {:?}", + head_state.slot() + )); + }; + if current_slot.epoch(E::slots_per_epoch()) + > head_state.slot().epoch(E::slots_per_epoch()) + ws_period + { + if self.chain_config.ignore_ws_check { + warn!( + head_slot=%head_state.slot(), + %current_slot, + "The current head state is outside the weak subjectivity period. You are currently running a node that is susceptible to long range attacks. \ + It is highly recommended to purge your db and checkpoint sync. For more information please \ + read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity" + ) + } + return Err( + "The current head state is outside the weak subjectivity period. A node in this state is susceptible to long range attacks. You should purge your db and \ + checkpoint sync. For more information please read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity \ + If you understand the risks, it is possible to ignore this error with the --ignore-ws-check flag.".to_string() + ); + } + let validator_pubkey_cache = self .validator_pubkey_cache .map(|mut validator_pubkey_cache| { @@ -916,9 +926,11 @@ where let genesis_validators_root = head_snapshot.beacon_state.genesis_validators_root(); let genesis_time = head_snapshot.beacon_state.genesis_time(); - let canonical_head = CanonicalHead::new(fork_choice, Arc::new(head_snapshot)); + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(head_snapshot), head_payload_status); let shuffling_cache_size = self.chain_config.shuffling_cache_size; let complete_blob_backfill = self.chain_config.complete_blob_backfill; + let enable_partial_columns = self.chain_config.enable_partial_columns; // Calculate the weak subjectivity point in which to backfill blocks to. let genesis_backfill_slot = if self.chain_config.genesis_backfill { @@ -1003,11 +1015,13 @@ where observed_aggregators: <_>::default(), // TODO: allow for persisting and loading the pool from disk. observed_sync_aggregators: <_>::default(), + observed_payload_attesters: <_>::default(), // TODO: allow for persisting and loading the pool from disk. observed_block_producers: <_>::default(), observed_column_sidecars: RwLock::new(ObservedDataSidecars::new(self.spec.clone())), observed_blob_sidecars: RwLock::new(ObservedDataSidecars::new(self.spec.clone())), observed_slashable: <_>::default(), + pending_payload_envelopes: <_>::default(), observed_voluntary_exits: <_>::default(), observed_proposer_slashings: <_>::default(), observed_attester_slashings: <_>::default(), @@ -1028,9 +1042,9 @@ where beacon_proposer_cache, inclusion_list_cache: <_>::default(), block_times_cache: <_>::default(), + envelope_times_cache: <_>::default(), pre_finalization_block_cache: <_>::default(), validator_pubkey_cache: RwLock::new(validator_pubkey_cache), - attester_cache: <_>::default(), early_attester_cache: <_>::default(), light_client_server_cache: LightClientServerCache::new(), light_client_server_tx: self.light_client_server_tx, @@ -1050,28 +1064,20 @@ where complete_blob_backfill, slot_clock, self.kzg.clone(), - store, Arc::new(custody_context), self.spec, + enable_partial_columns, ) .map_err(|e| format!("Error initializing DataAvailabilityChecker: {:?}", e))?, ), kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), + gossip_verified_payload_bid_cache: <_>::default(), + gossip_verified_proposer_preferences_cache: <_>::default(), }; let head = beacon_chain.head_snapshot(); - // Prime the attester cache with the head state. - beacon_chain - .attester_cache - .maybe_cache_state( - &head.beacon_state, - head.beacon_block_root, - &beacon_chain.spec, - ) - .map_err(|e| format!("Failed to prime attester cache: {:?}", e))?; - // Only perform the check if it was configured. if let Some(wss_checkpoint) = beacon_chain.config.weak_subjectivity_checkpoint && let Err(e) = beacon_chain.verify_weak_subjectivity_checkpoint( @@ -1100,6 +1106,11 @@ where let cgc_change_effective_slot = cgc_changed.effective_epoch.start_slot(E::slots_per_epoch()); beacon_chain.update_data_column_custody_info(Some(cgc_change_effective_slot)); + + // Persist change to disk. + beacon_chain + .persist_custody_context() + .map_err(|e| format!("Failed writing updated CGC: {e:?}"))?; } info!( @@ -1110,9 +1121,7 @@ where ); // Check for states to reconstruct (in the background). - if beacon_chain.config.reconstruct_historic_states - && beacon_chain.store.get_oldest_block_slot() == 0 - { + if beacon_chain.config.archive && beacon_chain.store.get_oldest_block_slot() == 0 { beacon_chain.store_migrator.process_reconstruction(); } @@ -1165,17 +1174,19 @@ where } } -fn genesis_block<E: EthSpec>( +fn make_genesis_block<E: EthSpec>( genesis_state: &mut BeaconState<E>, spec: &ChainSpec, ) -> Result<SignedBeaconBlock<E>, String> { - let mut genesis_block = BeaconBlock::empty(spec); - *genesis_block.state_root_mut() = genesis_state + let mut block = genesis_block(genesis_state, spec) + .map_err(|e| format!("Error building genesis block: {:?}", e))?; + + *block.state_root_mut() = genesis_state .update_tree_hash_cache() .map_err(|e| format!("Error hashing genesis state: {:?}", e))?; Ok(SignedBeaconBlock::from_block( - genesis_block, + block, // Empty signature, which should NEVER be read. This isn't to-spec, but makes the genesis // block consistent with every other block. Signature::empty(), @@ -1225,17 +1236,29 @@ fn build_data_columns_from_blobs<E: EthSpec>( .blob_kzg_commitments() .cloned() .map_err(|e| format!("Unexpected pre Deneb block: {e:?}"))?; - let kzg_commitments_inclusion_proof = beacon_block_body - .kzg_commitments_merkle_proof() - .map_err(|e| format!("Failed to compute kzg commitments merkle proof: {e:?}"))?; - build_data_column_sidecars( - kzg_commitments, - kzg_commitments_inclusion_proof, - block.signed_block_header(), - blob_cells_and_proofs_vec, - spec, - ) - .map_err(|e| format!("Failed to compute weak subjectivity data_columns: {e:?}"))? + + if block.fork_name_unchecked().gloas_enabled() { + build_data_column_sidecars_gloas( + block.message().tree_hash_root(), + block.slot(), + blob_cells_and_proofs_vec, + spec, + ) + .map_err(|e| format!("Failed to compute weak subjectivity data_columns: {e:?}"))? + } else { + let kzg_commitments_inclusion_proof = beacon_block_body + .kzg_commitments_merkle_proof() + .map_err(|e| format!("Failed to compute kzg commitments merkle proof: {e:?}"))?; + + build_data_column_sidecars_fulu( + kzg_commitments, + kzg_commitments_inclusion_proof, + block.signed_block_header(), + blob_cells_and_proofs_vec, + spec, + ) + .map_err(|e| format!("Failed to compute weak subjectivity data_columns: {e:?}"))? + } }; Ok(data_columns) } diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 8b513c9c53..9384e8202d 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -41,13 +41,13 @@ use crate::{ metrics, validator_monitor::get_slot_delay_ms, }; -use eth2::types::{EventKind, SseChainReorg, SseFinalizedCheckpoint, SseHead, SseLateHead}; +use eth2::types::{EventKind, SseChainReorg, SseFinalizedCheckpoint, SseLateHead}; use fork_choice::{ - ExecutionStatus, ForkChoiceStore, ForkChoiceView, ForkchoiceUpdateParameters, ProtoBlock, - ResetPayloadStatuses, + ExecutionStatus, ForkChoiceStore, ForkChoiceView, ForkchoiceUpdateParameters, PayloadStatus, + ProtoBlock, ResetPayloadStatuses, }; use itertools::process_results; -use lighthouse_tracing::SPAN_RECOMPUTE_HEAD; + use logging::crit; use parking_lot::{Mutex, RwLock, RwLockReadGuard, RwLockUpgradableReadGuard, RwLockWriteGuard}; use slot_clock::SlotClock; @@ -58,7 +58,6 @@ use store::{ Error as StoreError, KeyValueStore, KeyValueStoreOp, StoreConfig, iter::StateRootsIterator, }; use task_executor::{JoinHandle, ShutdownReason}; -use tracing::info_span; use tracing::{debug, error, info, instrument, warn}; use types::*; @@ -108,6 +107,8 @@ pub struct CachedHead<E: EthSpec> { /// This value may be distinct to the `self.snapshot.beacon_state.finalized_checkpoint`. /// This value should be used over the beacon state value in practically all circumstances. finalized_checkpoint: Checkpoint, + /// The payload status of the head block, as determined by fork choice. + head_payload_status: proto_array::PayloadStatus, /// The `execution_payload.block_hash` of the block at the head of the chain. Set to `None` /// before Bellatrix. head_hash: Option<ExecutionBlockHash>, @@ -237,6 +238,10 @@ impl<E: EthSpec> CachedHead<E> { finalized_hash: self.finalized_hash, } } + + pub fn head_payload_status(&self) -> proto_array::PayloadStatus { + self.head_payload_status + } } /// Represents the "canonical head" of the beacon chain. @@ -267,6 +272,7 @@ impl<T: BeaconChainTypes> CanonicalHead<T> { pub fn new( fork_choice: BeaconForkChoice<T>, snapshot: Arc<BeaconSnapshot<T::EthSpec>>, + head_payload_status: proto_array::PayloadStatus, ) -> Self { let fork_choice_view = fork_choice.cached_fork_choice_view(); let forkchoice_update_params = fork_choice.get_forkchoice_update_parameters(); @@ -274,6 +280,7 @@ impl<T: BeaconChainTypes> CanonicalHead<T> { snapshot, justified_checkpoint: fork_choice_view.justified_checkpoint, finalized_checkpoint: fork_choice_view.finalized_checkpoint, + head_payload_status, head_hash: forkchoice_update_params.head_hash, justified_hash: forkchoice_update_params.justified_hash, finalized_hash: forkchoice_update_params.finalized_hash, @@ -301,21 +308,34 @@ impl<T: BeaconChainTypes> CanonicalHead<T> { store: &BeaconStore<T>, spec: &ChainSpec, ) -> Result<(), Error> { - let fork_choice = + let mut fork_choice = <BeaconChain<T>>::load_fork_choice(store.clone(), reset_payload_statuses, spec)? .ok_or(Error::MissingPersistedForkChoice)?; + let current_slot_for_head = fork_choice.fc_store().get_current_slot(); + let (_, head_payload_status) = fork_choice.get_head(current_slot_for_head, spec)?; let fork_choice_view = fork_choice.cached_fork_choice_view(); let beacon_block_root = fork_choice_view.head_block_root; let beacon_block = store .get_full_block(&beacon_block_root)? .ok_or(Error::MissingBeaconBlock(beacon_block_root))?; let current_slot = fork_choice.fc_store().get_current_slot(); + let (_, beacon_state) = store .get_advanced_hot_state(beacon_block_root, current_slot, beacon_block.state_root())? .ok_or(Error::MissingBeaconState(beacon_block.state_root()))?; + // Load the execution envelope from the store if the head has a Full payload. + let execution_envelope = if head_payload_status == PayloadStatus::Full { + store + .get_payload_envelope(&beacon_block_root)? + .map(Arc::new) + } else { + None + }; + let snapshot = BeaconSnapshot { beacon_block_root, + execution_envelope, beacon_block: Arc::new(beacon_block), beacon_state, }; @@ -325,6 +345,7 @@ impl<T: BeaconChainTypes> CanonicalHead<T> { snapshot: Arc::new(snapshot), justified_checkpoint: fork_choice_view.justified_checkpoint, finalized_checkpoint: fork_choice_view.finalized_checkpoint, + head_payload_status, head_hash: forkchoice_update_params.head_hash, justified_hash: forkchoice_update_params.justified_hash, finalized_hash: forkchoice_update_params.finalized_hash, @@ -367,6 +388,26 @@ impl<T: BeaconChainTypes> CanonicalHead<T> { Ok((head, execution_status)) } + /// Returns `true` if the payload for this block is canonical (Full) according to fork choice. + pub fn block_has_canonical_payload( + &self, + root: &Hash256, + spec: &ChainSpec, + ) -> Result<bool, Error> { + let cached_head = self.cached_head(); + let head_root = cached_head.head_block_root(); + let head_payload_status = cached_head.head_payload_status(); + + if *root == head_root { + return Ok(head_payload_status == PayloadStatus::Full); + } + + self.fork_choice_read_lock() + .get_canonical_payload_status(root, spec) + .map(|status| status == PayloadStatus::Full) + .map_err(Error::ForkChoiceError) + } + /// Returns a clone of `self.cached_head`. /// /// Takes a read-lock on `self.cached_head` for a short time (just long enough to clone it). @@ -517,22 +558,15 @@ impl<T: BeaconChainTypes> BeaconChain<T> { /// such a case it's critical that the `BeaconChain` keeps importing blocks so that the /// situation can be rectified. We avoid returning an error here so that calling functions /// can't abort block import because an error is returned here. + #[instrument(name = "lh_recompute_head_at_slot", skip(self), level = "info", fields(slot = %current_slot))] pub async fn recompute_head_at_slot(self: &Arc<Self>, current_slot: Slot) { - let span = info_span!( - SPAN_RECOMPUTE_HEAD, - slot = %current_slot - ); - metrics::inc_counter(&metrics::FORK_CHOICE_REQUESTS); let _timer = metrics::start_timer(&metrics::FORK_CHOICE_TIMES); let chain = self.clone(); match self .spawn_blocking_handle( - move || { - let _guard = span.enter(); - chain.recompute_head_at_slot_internal(current_slot) - }, + move || chain.recompute_head_at_slot_internal(current_slot), "recompute_head_internal", ) .await @@ -598,11 +632,12 @@ impl<T: BeaconChainTypes> BeaconChain<T> { justified_checkpoint: old_cached_head.justified_checkpoint(), finalized_checkpoint: old_cached_head.finalized_checkpoint(), }; + let old_payload_status = old_cached_head.head_payload_status(); let mut fork_choice_write_lock = self.canonical_head.fork_choice_write_lock(); // Recompute the current head via the fork choice algorithm. - fork_choice_write_lock.get_head(current_slot, &self.spec)?; + let (_, new_payload_status) = fork_choice_write_lock.get_head(current_slot, &self.spec)?; // Downgrade the fork choice write-lock to a read lock, without allowing access to any // other writers. @@ -647,9 +682,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { }); } - // Exit early if the head or justified/finalized checkpoints have not changed, there's - // nothing to do. - if new_view == old_view { + // Exit early if the head, checkpoints, and payload status have not changed. + if new_view == old_view && new_payload_status == old_payload_status { debug!( head = ?new_view.head_block_root, "No change in canonical head" @@ -669,26 +703,42 @@ impl<T: BeaconChainTypes> BeaconChain<T> { drop(fork_choice_read_lock); // If the head has changed, update `self.canonical_head`. - let new_cached_head = if new_view.head_block_root != old_view.head_block_root { + let new_cached_head = if new_view.head_block_root != old_view.head_block_root + || new_payload_status != old_payload_status + { metrics::inc_counter(&metrics::FORK_CHOICE_CHANGED_HEAD); + // TODO(gloas): could optimise this to reuse state and rest of snapshot if just the + // payload status has changed. let mut new_snapshot = { let beacon_block = self .store .get_full_block(&new_view.head_block_root)? .ok_or(Error::MissingBeaconBlock(new_view.head_block_root))?; + // Load the execution envelope from the store if the head has a Full payload. + let state_root = beacon_block.state_root(); + let execution_envelope = if new_payload_status == PayloadStatus::Full { + let envelope = self + .store + .get_payload_envelope(&new_view.head_block_root)? + .map(Arc::new) + .ok_or(Error::MissingExecutionPayloadEnvelope( + new_view.head_block_root, + ))?; + + Some(envelope) + } else { + None + }; let (_, beacon_state) = self .store - .get_advanced_hot_state( - new_view.head_block_root, - current_slot, - beacon_block.state_root(), - )? - .ok_or(Error::MissingBeaconState(beacon_block.state_root()))?; + .get_advanced_hot_state(new_view.head_block_root, current_slot, state_root)? + .ok_or(Error::MissingBeaconState(state_root))?; BeaconSnapshot { beacon_block: Arc::new(beacon_block), + execution_envelope, beacon_block_root: new_view.head_block_root, beacon_state, } @@ -702,6 +752,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { snapshot: Arc::new(new_snapshot), justified_checkpoint: new_view.justified_checkpoint, finalized_checkpoint: new_view.finalized_checkpoint, + head_payload_status: new_payload_status, head_hash: new_forkchoice_update_parameters.head_hash, justified_hash: new_forkchoice_update_parameters.justified_hash, finalized_hash: new_forkchoice_update_parameters.finalized_hash, @@ -729,6 +780,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { snapshot: old_cached_head.snapshot.clone(), justified_checkpoint: new_view.justified_checkpoint, finalized_checkpoint: new_view.finalized_checkpoint, + head_payload_status: new_payload_status, head_hash: new_forkchoice_update_parameters.head_hash, justified_hash: new_forkchoice_update_parameters.justified_hash, finalized_hash: new_forkchoice_update_parameters.finalized_hash, @@ -749,7 +801,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let new_snapshot = &new_cached_head.snapshot; let old_snapshot = &old_cached_head.snapshot; - // If the head changed, perform some updates. + // Only run on head *block* changes - payload status changes only need the + // `cached_head` update above, not re-org detection or event emission. if new_snapshot.beacon_block_root != old_snapshot.beacon_block_root && let Err(e) = self.after_new_head(&old_cached_head, &new_cached_head, new_head_proto_block) @@ -779,8 +832,11 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // The execution layer updates might attempt to take a write-lock on fork choice, so it's // important to ensure the fork-choice lock isn't being held. - let el_update_handle = - spawn_execution_layer_updates(self.clone(), new_forkchoice_update_parameters)?; + let el_update_handle = spawn_execution_layer_updates( + self.clone(), + new_forkchoice_update_parameters, + new_payload_status, + )?; // We have completed recomputing the head and it's now valid for another process to do the // same. @@ -829,15 +885,8 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .slot() .epoch(T::EthSpec::slots_per_epoch()); - // These fields are used for server-sent events. - let state_root = new_snapshot.beacon_state_root(); + // This field is used for server-sent events. let head_slot = new_snapshot.beacon_state.slot(); - let dependent_root = new_snapshot - .beacon_state - .attester_shuffling_decision_root(self.genesis_block_root, RelativeEpoch::Next); - let prev_dependent_root = new_snapshot - .beacon_state - .attester_shuffling_decision_root(self.genesis_block_root, RelativeEpoch::Current); match BlockShufflingIds::try_from_head( new_snapshot.beacon_block_root, @@ -868,6 +917,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .as_utf8_lossy(), &self.slot_clock, self.event_handler.as_ref(), + &self.spec, ); if is_epoch_transition || reorg_distance.is_some() { @@ -875,33 +925,6 @@ impl<T: BeaconChainTypes> BeaconChain<T> { self.op_pool.prune_attestations(self.epoch()?); } - // Register server-sent-events for a new head. - if let Some(event_handler) = self - .event_handler - .as_ref() - .filter(|handler| handler.has_head_subscribers()) - { - match (dependent_root, prev_dependent_root) { - (Ok(current_duty_dependent_root), Ok(previous_duty_dependent_root)) => { - event_handler.register(EventKind::Head(SseHead { - slot: head_slot, - block: new_snapshot.beacon_block_root, - state: state_root, - current_duty_dependent_root, - previous_duty_dependent_root, - epoch_transition: is_epoch_transition, - execution_optimistic: new_head_is_optimistic, - })); - } - (Err(e), _) | (_, Err(e)) => { - warn!( - error = ?e, - "Unable to find dependent roots, cannot register head event" - ); - } - } - } - // Register a server-sent-event for a reorg (if necessary). if let Some(depth) = reorg_distance && let Some(event_handler) = self @@ -956,15 +979,19 @@ impl<T: BeaconChainTypes> BeaconChain<T> { .start_slot(T::EthSpec::slots_per_epoch()), ); - self.observed_slashable.write().prune( + self.observed_column_sidecars.write().prune( new_view .finalized_checkpoint .epoch .start_slot(T::EthSpec::slots_per_epoch()), ); - self.attester_cache - .prune_below(new_view.finalized_checkpoint.epoch); + self.observed_slashable.write().prune( + new_view + .finalized_checkpoint + .epoch + .start_slot(T::EthSpec::slots_per_epoch()), + ); if let Some(event_handler) = self.event_handler.as_ref() && event_handler.has_finalized_subscribers() @@ -983,26 +1010,30 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // The store migration task and op pool pruning require the *state at the first slot of the // finalized epoch*, rather than the state of the latest finalized block. These two values // will only differ when the first slot of the finalized epoch is a skip slot. - // - // Use the `StateRootsIterator` directly rather than `BeaconChain::state_root_at_slot` - // to ensure we use the same state that we just set as the head. let new_finalized_slot = new_view .finalized_checkpoint .epoch .start_slot(T::EthSpec::slots_per_epoch()); - let new_finalized_state_root = process_results( - StateRootsIterator::new(&self.store, &new_snapshot.beacon_state), - |mut iter| { - iter.find_map(|(state_root, slot)| { - if slot == new_finalized_slot { - Some(state_root) - } else { - None - } - }) - }, - )? - .ok_or(Error::MissingFinalizedStateRoot(new_finalized_slot))?; + let new_finalized_state_root = if new_finalized_slot == finalized_proto_block.slot { + // Fast-path for the common case where the finalized state is not at a skipped slot. + finalized_proto_block.state_root + } else { + // Use the `StateRootsIterator` directly rather than `BeaconChain::state_root_at_slot` + // to ensure we use the same state that we just set as the head. + process_results( + StateRootsIterator::new(&self.store, &new_snapshot.beacon_state), + |mut iter| { + iter.find_map(|(state_root, slot)| { + if slot == new_finalized_slot { + Some(state_root) + } else { + None + } + }) + }, + )? + .ok_or(Error::MissingFinalizedStateRoot(new_finalized_slot))? + }; let update_cache = true; let new_finalized_state = self @@ -1163,6 +1194,7 @@ fn perform_debug_logging<T: BeaconChainTypes>( fn spawn_execution_layer_updates<T: BeaconChainTypes>( chain: Arc<BeaconChain<T>>, forkchoice_update_params: ForkchoiceUpdateParameters, + head_payload_status: PayloadStatus, ) -> Result<JoinHandle<Option<()>>, Error> { let current_slot = chain .slot_clock @@ -1185,6 +1217,7 @@ fn spawn_execution_layer_updates<T: BeaconChainTypes>( .update_execution_engine_forkchoice( current_slot, forkchoice_update_params, + head_payload_status, OverrideForkchoiceUpdate::Yes, ) .await @@ -1334,6 +1367,7 @@ fn observe_head_block_delays<E: EthSpec, S: SlotClock>( head_block_graffiti: String, slot_clock: &S, event_handler: Option<&ServerSentEventHandler<E>>, + spec: &ChainSpec, ) { let Some(block_time_set_as_head) = slot_clock.now_duration() else { // Practically unreachable: the slot clock's time should not be before the UNIX epoch. @@ -1392,8 +1426,8 @@ fn observe_head_block_delays<E: EthSpec, S: SlotClock>( .as_millis() as i64, ); - // The time from the start of the slot when all blobs have been observed. Technically this - // is the time we last saw a blob related to this block/slot. + // The time from the start of the slot when all blobs/data columns have been observed. Technically this + // is the time we last saw a blob/data column related to this block/slot. metrics::set_gauge( &metrics::BEACON_BLOB_DELAY_ALL_OBSERVED_SLOT_START, block_delays @@ -1463,7 +1497,7 @@ fn observe_head_block_delays<E: EthSpec, S: SlotClock>( // Determine whether the block has been set as head too late for proper attestation // production. - let late_head = attestable_delay >= slot_clock.unagg_attestation_production_delay(); + let late_head = attestable_delay >= spec.get_unaggregated_attestation_due(); // If the block was enshrined as head too late for attestations to be created for it, // log a debug warning and increment a metric. diff --git a/beacon_node/beacon_chain/src/chain_config.rs b/beacon_node/beacon_chain/src/chain_config.rs index 1f5abc4891..b2c017a469 100644 --- a/beacon_node/beacon_chain/src/chain_config.rs +++ b/beacon_node/beacon_chain/src/chain_config.rs @@ -38,7 +38,7 @@ pub struct ChainConfig { /// If `None`, there is no weak subjectivity verification. pub weak_subjectivity_checkpoint: Option<Checkpoint>, /// Determine whether to reconstruct historic states, usually after a checkpoint sync. - pub reconstruct_historic_states: bool, + pub archive: bool, /// The max size of a message that can be sent over the network. pub max_network_size: usize, /// Maximum percentage of the head committee weight at which to attempt re-orging the canonical head. @@ -117,8 +117,12 @@ pub struct ChainConfig { /// On Holesky there is a block which is added to this set by default but which can be removed /// by using `--invalid-block-roots ""`. pub invalid_block_roots: HashSet<Hash256>, + /// When set to true, the beacon node can be started even if the head state is outside the weak subjectivity period. + pub ignore_ws_check: bool, /// Disable the getBlobs optimisation to fetch blobs from the EL mempool. pub disable_get_blobs: bool, + /// Whether to enable partial data column support. + pub enable_partial_columns: bool, /// The node's custody type, determining how many data columns to custody and sample. pub node_custody_type: NodeCustodyType, } @@ -128,7 +132,7 @@ impl Default for ChainConfig { Self { import_max_skip_slots: None, weak_subjectivity_checkpoint: None, - reconstruct_historic_states: false, + archive: false, max_network_size: 10 * 1_048_576, // 10M re_org_head_threshold: Some(DEFAULT_RE_ORG_HEAD_THRESHOLD), re_org_parent_threshold: Some(DEFAULT_RE_ORG_PARENT_THRESHOLD), @@ -160,7 +164,9 @@ impl Default for ChainConfig { block_publishing_delay: None, data_column_publishing_delay: None, invalid_block_roots: HashSet::new(), + ignore_ws_check: false, disable_get_blobs: false, + enable_partial_columns: false, node_custody_type: NodeCustodyType::Fullnode, } } @@ -168,11 +174,9 @@ impl Default for ChainConfig { impl ChainConfig { /// The latest delay from the start of the slot at which to attempt a 1-slot re-org. - pub fn re_org_cutoff(&self, seconds_per_slot: u64) -> Duration { + pub fn re_org_cutoff(&self, slot_duration: Duration) -> Duration { self.re_org_cutoff_millis .map(Duration::from_millis) - .unwrap_or_else(|| { - Duration::from_secs(seconds_per_slot) / DEFAULT_RE_ORG_CUTOFF_DENOMINATOR - }) + .unwrap_or_else(|| slot_duration / DEFAULT_RE_ORG_CUTOFF_DENOMINATOR) } } diff --git a/beacon_node/beacon_chain/src/custody_context.rs b/beacon_node/beacon_chain/src/custody_context.rs index c512ce616a..72f62db1b4 100644 --- a/beacon_node/beacon_chain/src/custody_context.rs +++ b/beacon_node/beacon_chain/src/custody_context.rs @@ -113,8 +113,9 @@ impl ValidatorRegistrations { // Apply the change from the next epoch after adding some delay buffer to ensure // the node has enough time to subscribe to subnets etc, and to avoid having // inconsistent column counts within an epoch. - let effective_delay_slots = - CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS / spec.seconds_per_slot; + let effective_delay_slots = CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS + .checked_div(spec.get_slot_duration().as_secs()) + .unwrap_or(1); let effective_epoch = (current_slot + effective_delay_slots).epoch(E::slots_per_epoch()) + 1; self.epoch_validator_custody_requirements diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index 3e859456b1..9d8b76aaed 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -1,17 +1,16 @@ use crate::blob_verification::{ GossipVerifiedBlob, KzgVerifiedBlob, KzgVerifiedBlobList, verify_kzg_for_blob_list, }; -use crate::block_verification_types::{ - AvailabilityPendingExecutedBlock, AvailableExecutedBlock, RpcBlock, -}; +use crate::block_verification_types::{AvailabilityPendingExecutedBlock, AvailableExecutedBlock}; use crate::data_availability_checker::overflow_lru_cache::{ DataAvailabilityCheckerInner, ReconstructColumnsDecision, }; -use crate::{ - BeaconChain, BeaconChainTypes, BeaconStore, BlockProcessStatus, CustodyContext, metrics, -}; +use crate::partial_data_column_assembler::{AssemblyColumn, PartialDataColumnAssembler}; +use crate::{BeaconChain, BeaconChainTypes, BlockProcessStatus, CustodyContext, metrics}; +use educe::Educe; use kzg::Kzg; use slot_clock::SlotClock; +use std::collections::HashSet; use std::fmt; use std::fmt::Debug; use std::num::NonZeroUsize; @@ -19,27 +18,26 @@ use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; use tracing::{debug, error, instrument}; -use types::blob_sidecar::{BlobIdentifier, BlobSidecar, FixedBlobSidecarList}; +use types::data::{BlobIdentifier, FixedBlobSidecarList, PartialDataColumn}; use types::{ - BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, DataColumnSidecarList, Epoch, - EthSpec, Hash256, SignedBeaconBlock, Slot, + BlobSidecar, BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, + DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarError, + PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, new_non_zero_usize, }; mod error; mod overflow_lru_cache; -mod state_lru_cache; use crate::data_availability_checker::error::Error; use crate::data_column_verification::{ - CustodyDataColumn, GossipVerifiedDataColumn, KzgVerifiedCustodyDataColumn, - KzgVerifiedDataColumn, verify_kzg_for_data_column_list, + GossipVerifiedDataColumn, KzgVerifiedCustodyDataColumn, KzgVerifiedDataColumn, + verify_kzg_for_data_column_list, }; use crate::metrics::{ KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS, KZG_DATA_COLUMN_RECONSTRUCTION_FAILURES, }; use crate::observed_data_sidecars::ObservationStrategy; pub use error::{Error as AvailabilityCheckError, ErrorCategory as AvailabilityCheckErrorCategory}; -use types::non_zero_usize::new_non_zero_usize; /// The LRU Cache stores `PendingComponents`, which store block and its associated blob data: /// @@ -53,7 +51,6 @@ use types::non_zero_usize::new_non_zero_usize; /// `PendingComponents` are now never removed from the cache manually are only removed via LRU /// eviction to prevent race conditions (#7961), so we expect this cache to be full all the time. const OVERFLOW_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); -const STATE_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); /// Cache to hold fully valid data that can't be imported to fork-choice yet. After Dencun hard-fork /// blocks have a sidecar of data that is received separately from the network. We call the concept @@ -82,6 +79,7 @@ const STATE_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); pub struct DataAvailabilityChecker<T: BeaconChainTypes> { complete_blob_backfill: bool, availability_cache: Arc<DataAvailabilityCheckerInner<T>>, + partial_assembler: Option<Arc<PartialDataColumnAssembler<T::EthSpec>>>, slot_clock: T::SlotClock, kzg: Arc<Kzg>, custody_context: Arc<CustodyContext<T::EthSpec>>, @@ -122,18 +120,25 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { complete_blob_backfill: bool, slot_clock: T::SlotClock, kzg: Arc<Kzg>, - store: BeaconStore<T>, custody_context: Arc<CustodyContext<T::EthSpec>>, spec: Arc<ChainSpec>, + enable_partial_columns: bool, ) -> Result<Self, AvailabilityCheckError> { let inner = DataAvailabilityCheckerInner::new( OVERFLOW_LRU_CAPACITY_NON_ZERO, - store, custody_context.clone(), spec.clone(), )?; + let partial_assembler = if enable_partial_columns { + Some(Arc::new(PartialDataColumnAssembler::new( + OVERFLOW_LRU_CAPACITY_NON_ZERO, + ))) + } else { + None + }; Ok(Self { complete_blob_backfill, + partial_assembler, availability_cache: Arc::new(inner), slot_clock, kzg, @@ -146,6 +151,10 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { &self.custody_context } + pub fn partial_assembler(&self) -> Option<&Arc<PartialDataColumnAssembler<T::EthSpec>>> { + self.partial_assembler.as_ref() + } + /// Checks if the block root is currently in the availability cache awaiting import because /// of missing components. /// @@ -178,19 +187,104 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { }) } - /// Check if the exact data column is in the availability cache. - pub fn is_data_column_cached( - &self, - block_root: &Hash256, - data_column: &DataColumnSidecar<T::EthSpec>, - ) -> bool { - self.availability_cache - .peek_pending_components(block_root, |components| { - components.is_some_and(|components| { - let cached_column_opt = components.get_cached_data_column(data_column.index); - cached_column_opt.is_some_and(|cached| *cached == *data_column) + /// Filter out all cells that are already cached for the given `block_root`. + /// Returns None if all cells are already cached. + /// Returns an error if any cells or proofs mismatch the cached cells. + pub fn missing_cells_for_column_sidecar<'a>( + &'_ self, + data_column: &'a DataColumnSidecar<T::EthSpec>, + ) -> Result<Option<PartialDataColumnSidecarRef<'a, T::EthSpec>>, MissingCellsError> { + let block_root = data_column.block_root(); + let column_index = *data_column.index(); + + // Check DA checker cache first - if we have a full column cached, nothing is missing. + // We return Some(true) from the peek if it exists and matches, Some(false) if it exists but + // does not match, and None if it doesn't exist. + if let Some(matches) = + self.availability_cache + .peek_pending_components(&block_root, |components| { + components + .and_then(|c| c.get_cached_data_column(column_index)) + .map(|cached| *cached == *data_column) }) + { + return if matches { + Ok(None) + } else { + Err(MissingCellsError::MismatchesCachedColumn) + }; + } + + // Check assembler for partial columns + if let Some(assembler) = &self.partial_assembler { + match assembler.get_partial(&block_root, column_index) { + Some(AssemblyColumn::Incomplete(cached_partial)) => { + return data_column.try_filter_to_partial_ref(|idx, cell, proof| { + match cached_partial.as_data_column().sidecar.get(idx) { + None => Ok(true), + Some((cached_cell, cached_proof)) => { + if cell == cached_cell && proof == cached_proof { + Ok(false) + } else { + Err(MissingCellsError::MismatchesCachedColumn) + } + } + } + }); + } + // This can happen if the column has been marked as completed already but has not + // reached the availability cache yet. + Some(AssemblyColumn::Complete(_)) => { + return Ok(None); + } + None => { + // No cached data, all cells are "missing" (new data we want) + } + } + } + // No cached data, all cells are "missing" (new data we want) + data_column.try_filter_to_partial_ref(|_, _, _| Ok(true)) + } + + /// Filter out all cells that are already cached for the given `block_root`. + /// Returns input for kzg verification, or None if all cells are already cached. + pub fn missing_cells_for_partial_column_sidecar<'a>( + &'_ self, + partial_data_column: &'a PartialDataColumn<T::EthSpec>, + ) -> Result<Option<PartialDataColumnSidecarRef<'a, T::EthSpec>>, MissingCellsError> { + let column_index = partial_data_column.index; + let block_root = partial_data_column.block_root; + + // Check DA checker cache first - if we have a full column cached, nothing is missing. + if self + .availability_cache + .peek_pending_components(&block_root, |components| { + components.is_some_and(|c| c.get_cached_data_column(column_index).is_some()) }) + { + return Ok(None); + } + + // Check assembler for partial columns + if let Some(assembler) = &self.partial_assembler { + match assembler.get_partial(&block_root, column_index) { + Some(AssemblyColumn::Incomplete(cached_partial)) => { + return Ok(partial_data_column.sidecar.filter(|idx| { + cached_partial.as_data_column().sidecar.get(idx).is_none() + })?); + } + // This can happen if the column has been marked as completed already but has not + // reached the availability cache yet. + Some(AssemblyColumn::Complete(_)) => { + return Ok(None); + } + None => { + // No cached data, all cells are "missing" (new data we want) + } + } + } + // No cached data, all cells are "missing" (new data we want) + Ok(partial_data_column.sidecar.filter(|_| true)?) } /// Get a blob from the availability cache. @@ -301,7 +395,8 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { /// have a block cached, return the `Availability` variant triggering block import. /// Otherwise cache the data column sidecar. /// - /// This should only accept gossip verified data columns, so we should not have to worry about dupes. + /// This should only accept gossip verified full data columns (not partials). + /// Partials are assembled in PartialDataColumnAssembler. #[instrument(skip_all, level = "trace")] pub fn put_gossip_verified_data_columns< O: ObservationStrategy, @@ -322,10 +417,18 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { .map(|c| KzgVerifiedCustodyDataColumn::from_asserted_custody(c.into_inner())) .collect::<Vec<_>>(); + if let Some(assembler) = &self.partial_assembler { + for column in &custody_columns { + assembler.mark_as_complete(block_root, column); + } + } + self.availability_cache .put_kzg_verified_data_columns(block_root, custody_columns) } + /// Put KZG-verified full custody data columns. + /// Only accepts full columns. Partials are assembled in PartialDataColumnAssembler. #[instrument(skip_all, level = "trace")] pub fn put_kzg_verified_custody_data_columns< I: IntoIterator<Item = KzgVerifiedCustodyDataColumn<T::EthSpec>>, @@ -344,6 +447,12 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { &self, executed_block: AvailabilityPendingExecutedBlock<T::EthSpec>, ) -> Result<Availability<T::EthSpec>, AvailabilityCheckError> { + let block = executed_block.as_block(); + if let Some(assembler) = &self.partial_assembler + && let Ok(header) = block.try_into() + { + assembler.init(executed_block.import_data.block_root, Arc::new(header)); + } self.availability_cache.put_executed_block(executed_block) } @@ -355,6 +464,11 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { block: Arc<SignedBeaconBlock<T::EthSpec>>, source: BlockImportSource, ) -> Result<(), Error> { + if let Some(assembler) = &self.partial_assembler + && let Ok(header) = block.as_ref().try_into() + { + assembler.init(block_root, Arc::new(header)); + } self.availability_cache .put_pre_execution_block(block_root, block, source) } @@ -366,151 +480,51 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { .remove_pre_execution_block(block_root); } - /// Verifies kzg commitments for an RpcBlock, returns a `MaybeAvailableBlock` that may - /// include the fully available block. - /// - /// WARNING: This function assumes all required blobs are already present, it does NOT - /// check if there are any missing blobs. - pub fn verify_kzg_for_rpc_block( + /// Verifies kzg commitments for an `AvailableBlock`. + pub fn verify_kzg_for_available_block( &self, - block: RpcBlock<T::EthSpec>, - ) -> Result<MaybeAvailableBlock<T::EthSpec>, AvailabilityCheckError> { - let (block_root, block, blobs, data_columns) = block.deconstruct(); - if self.blobs_required_for_block(&block) { - return if let Some(blob_list) = blobs { - verify_kzg_for_blob_list(blob_list.iter(), &self.kzg) - .map_err(AvailabilityCheckError::InvalidBlobs)?; - Ok(MaybeAvailableBlock::Available(AvailableBlock { - block_root, - block, - blob_data: AvailableBlockData::Blobs(blob_list), - blobs_available_timestamp: None, - spec: self.spec.clone(), - })) - } else { - Ok(MaybeAvailableBlock::AvailabilityPending { block_root, block }) - }; + available_block: &AvailableBlock<T::EthSpec>, + ) -> Result<(), AvailabilityCheckError> { + match available_block.data() { + AvailableBlockData::NoData => Ok(()), + AvailableBlockData::Blobs(blobs) => verify_kzg_for_blob_list(blobs.iter(), &self.kzg) + .map_err(AvailabilityCheckError::InvalidBlobs), + AvailableBlockData::DataColumns(columns) => { + verify_kzg_for_data_column_list(columns.iter(), &self.kzg) + .map_err(AvailabilityCheckError::InvalidColumn) + } } - if self.data_columns_required_for_block(&block) { - return if let Some(data_column_list) = data_columns.as_ref() { - verify_kzg_for_data_column_list( - data_column_list - .iter() - .map(|custody_column| custody_column.as_data_column()), - &self.kzg, - ) - .map_err(AvailabilityCheckError::InvalidColumn)?; - Ok(MaybeAvailableBlock::Available(AvailableBlock { - block_root, - block, - blob_data: AvailableBlockData::DataColumns( - data_column_list - .into_iter() - .map(|d| d.clone_arc()) - .collect(), - ), - blobs_available_timestamp: None, - spec: self.spec.clone(), - })) - } else { - Ok(MaybeAvailableBlock::AvailabilityPending { block_root, block }) - }; - } - - Ok(MaybeAvailableBlock::Available(AvailableBlock { - block_root, - block, - blob_data: AvailableBlockData::NoData, - blobs_available_timestamp: None, - spec: self.spec.clone(), - })) } - /// Checks if a vector of blocks are available. Returns a vector of `MaybeAvailableBlock` - /// This is more efficient than calling `verify_kzg_for_rpc_block` in a loop as it does - /// all kzg verification at once - /// - /// WARNING: This function assumes all required blobs are already present, it does NOT - /// check if there are any missing blobs. + /// Performs batch kzg verification for a vector of `AvailableBlocks`. This is more efficient than + /// calling `verify_kzg_for_available_block` in a loop. #[instrument(skip_all)] - pub fn verify_kzg_for_rpc_blocks( + pub fn batch_verify_kzg_for_available_blocks( &self, - blocks: Vec<RpcBlock<T::EthSpec>>, - ) -> Result<Vec<MaybeAvailableBlock<T::EthSpec>>, AvailabilityCheckError> { - let mut results = Vec::with_capacity(blocks.len()); - let all_blobs = blocks - .iter() - .filter(|block| self.blobs_required_for_block(block.as_block())) - // this clone is cheap as it's cloning an Arc - .filter_map(|block| block.blobs().cloned()) - .flatten() - .collect::<Vec<_>>(); + available_blocks: &[AvailableBlock<T::EthSpec>], + ) -> Result<(), AvailabilityCheckError> { + let mut all_blobs = Vec::new(); + let mut all_data_columns = Vec::new(); + + for available_block in available_blocks { + match available_block.data().to_owned() { + AvailableBlockData::NoData => {} + AvailableBlockData::Blobs(blobs) => all_blobs.extend(blobs), + AvailableBlockData::DataColumns(columns) => all_data_columns.extend(columns), + } + } - // verify kzg for all blobs at once if !all_blobs.is_empty() { verify_kzg_for_blob_list(all_blobs.iter(), &self.kzg) .map_err(AvailabilityCheckError::InvalidBlobs)?; } - let all_data_columns = blocks - .iter() - .filter(|block| self.data_columns_required_for_block(block.as_block())) - // this clone is cheap as it's cloning an Arc - .filter_map(|block| block.custody_columns().cloned()) - .flatten() - .map(CustodyDataColumn::into_inner) - .collect::<Vec<_>>(); - - // verify kzg for all data columns at once if !all_data_columns.is_empty() { - // Attributes fault to the specific peer that sent an invalid column verify_kzg_for_data_column_list(all_data_columns.iter(), &self.kzg) .map_err(AvailabilityCheckError::InvalidColumn)?; } - for block in blocks { - let (block_root, block, blobs, data_columns) = block.deconstruct(); - - let maybe_available_block = if self.blobs_required_for_block(&block) { - if let Some(blobs) = blobs { - MaybeAvailableBlock::Available(AvailableBlock { - block_root, - block, - blob_data: AvailableBlockData::Blobs(blobs), - blobs_available_timestamp: None, - spec: self.spec.clone(), - }) - } else { - MaybeAvailableBlock::AvailabilityPending { block_root, block } - } - } else if self.data_columns_required_for_block(&block) { - if let Some(data_columns) = data_columns { - MaybeAvailableBlock::Available(AvailableBlock { - block_root, - block, - blob_data: AvailableBlockData::DataColumns( - data_columns.into_iter().map(|d| d.into_inner()).collect(), - ), - blobs_available_timestamp: None, - spec: self.spec.clone(), - }) - } else { - MaybeAvailableBlock::AvailabilityPending { block_root, block } - } - } else { - MaybeAvailableBlock::Available(AvailableBlock { - block_root, - block, - blob_data: AvailableBlockData::NoData, - blobs_available_timestamp: None, - spec: self.spec.clone(), - }) - }; - - results.push(maybe_available_block); - } - - Ok(results) + Ok(()) } /// Determines the blob requirements for a block. If the block is pre-deneb, no blobs are required. @@ -569,7 +583,6 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { /// Collects metrics from the data availability checker. pub fn metrics(&self) -> DataAvailabilityCheckerMetrics { DataAvailabilityCheckerMetrics { - state_cache_size: self.availability_cache.state_cache_size(), block_cache_size: self.availability_cache.block_cache_size(), } } @@ -665,7 +678,6 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> { /// Helper struct to group data availability checker metrics. pub struct DataAvailabilityCheckerMetrics { - pub state_cache_size: usize, pub block_cache_size: usize, } @@ -676,8 +688,12 @@ pub fn start_availability_cache_maintenance_service<T: BeaconChainTypes>( // this cache only needs to be maintained if deneb is configured if chain.spec.deneb_fork_epoch.is_some() { let overflow_cache = chain.data_availability_checker.availability_cache.clone(); + let partial_assembler = chain.data_availability_checker.partial_assembler.clone(); executor.spawn( - async move { availability_cache_maintenance_service(chain, overflow_cache).await }, + async move { + availability_cache_maintenance_service(chain, overflow_cache, partial_assembler) + .await + }, "availability_cache_service", ); } else { @@ -688,6 +704,7 @@ pub fn start_availability_cache_maintenance_service<T: BeaconChainTypes>( async fn availability_cache_maintenance_service<T: BeaconChainTypes>( chain: Arc<BeaconChain<T>>, overflow_cache: Arc<DataAvailabilityCheckerInner<T>>, + partial_assembler: Option<Arc<PartialDataColumnAssembler<T::EthSpec>>>, ) { let epoch_duration = chain.slot_clock.slot_duration() * T::EthSpec::slots_per_epoch() as u32; loop { @@ -739,6 +756,9 @@ async fn availability_cache_maintenance_service<T: BeaconChainTypes>( if let Err(e) = overflow_cache.do_maintenance(cutoff_epoch) { error!(error = ?e,"Failed to maintain availability cache"); } + if let Some(assembler) = &partial_assembler { + assembler.do_maintenance(cutoff_epoch); + } } None => { error!("Failed to read slot clock"); @@ -749,7 +769,8 @@ async fn availability_cache_maintenance_service<T: BeaconChainTypes>( } } -#[derive(Debug)] +#[derive(Debug, Clone)] +// TODO(#8633) move this to `block_verification_types.rs` pub enum AvailableBlockData<E: EthSpec> { /// Block is pre-Deneb or has zero blobs NoData, @@ -759,31 +780,161 @@ pub enum AvailableBlockData<E: EthSpec> { DataColumns(DataColumnSidecarList<E>), } +impl<E: EthSpec> AvailableBlockData<E> { + pub fn new_with_blobs(blobs: BlobSidecarList<E>) -> Self { + if blobs.is_empty() { + Self::NoData + } else { + Self::Blobs(blobs) + } + } + + pub fn new_with_data_columns(columns: DataColumnSidecarList<E>) -> Self { + if columns.is_empty() { + Self::NoData + } else { + Self::DataColumns(columns) + } + } + + pub fn blobs(&self) -> Option<BlobSidecarList<E>> { + match self { + AvailableBlockData::NoData => None, + AvailableBlockData::Blobs(blobs) => Some(blobs.clone()), + AvailableBlockData::DataColumns(_) => None, + } + } + + pub fn blobs_len(&self) -> usize { + if let Some(blobs) = self.blobs() { + blobs.len() + } else { + 0 + } + } + + pub fn data_columns(&self) -> Option<DataColumnSidecarList<E>> { + match self { + AvailableBlockData::NoData => None, + AvailableBlockData::Blobs(_) => None, + AvailableBlockData::DataColumns(data_columns) => Some(data_columns.clone()), + } + } + + pub fn data_columns_len(&self) -> usize { + if let Some(data_columns) = self.data_columns() { + data_columns.len() + } else { + 0 + } + } +} + /// A fully available block that is ready to be imported into fork choice. -#[derive(Debug)] +#[derive(Debug, Clone, Educe)] +#[educe(Hash(bound(E: EthSpec)))] pub struct AvailableBlock<E: EthSpec> { block_root: Hash256, block: Arc<SignedBeaconBlock<E>>, + #[educe(Hash(ignore))] blob_data: AvailableBlockData<E>, + #[educe(Hash(ignore))] /// Timestamp at which this block first became available (UNIX timestamp, time since 1970). blobs_available_timestamp: Option<Duration>, + #[educe(Hash(ignore))] pub spec: Arc<ChainSpec>, } impl<E: EthSpec> AvailableBlock<E> { - pub fn __new_for_testing( - block_root: Hash256, - block: Arc<SignedBeaconBlock<E>>, - data: AvailableBlockData<E>, + /// Constructs an `AvailableBlock` from a block and blob data. + /// + /// This function validates that: + /// - Block data is not provided when not required (pre-Deneb or past DA boundary) + /// - Required blobs are present and match the expected count + /// - Required custody columns are complete based on the node's custody requirements + /// - KZG commitments in blobs match those in the block + /// + /// Returns `AvailabilityCheckError` if: + /// - `InvalidAvailableBlockData`: Block data is provided but not required + /// - `MissingBlobs`: Block requires blobs but they are missing or incomplete + /// - `MissingCustodyColumns`: Block requires custody columns but they are incomplete + /// - `KzgCommitmentMismatch`: Blob KZG commitment doesn't match block commitment + pub fn new<T>( + block: Arc<SignedBeaconBlock<T::EthSpec>>, + block_data: AvailableBlockData<T::EthSpec>, + da_checker: &DataAvailabilityChecker<T>, spec: Arc<ChainSpec>, - ) -> Self { - Self { - block_root, - block, - blob_data: data, - blobs_available_timestamp: None, - spec, + ) -> Result<Self, AvailabilityCheckError> + where + T: BeaconChainTypes<EthSpec = E>, + { + // Ensure block availability + let blobs_required = da_checker.blobs_required_for_block(&block); + let columns_required = da_checker.data_columns_required_for_block(&block); + + match &block_data { + AvailableBlockData::NoData => { + if columns_required { + return Err(AvailabilityCheckError::MissingCustodyColumns); + } else if blobs_required { + return Err(AvailabilityCheckError::MissingBlobs); + } + } + AvailableBlockData::Blobs(blobs) => { + if !blobs_required { + return Err(AvailabilityCheckError::InvalidAvailableBlockData); + } + + let Ok(block_kzg_commitments) = block.message().body().blob_kzg_commitments() + else { + return Err(AvailabilityCheckError::Unexpected( + "Expected blobs but could not fetch KZG commitments from the block" + .to_owned(), + )); + }; + + if blobs.len() != block_kzg_commitments.len() { + return Err(AvailabilityCheckError::MissingBlobs); + } + + for (blob, &block_kzg_commitment) in blobs.iter().zip(block_kzg_commitments.iter()) + { + if blob.kzg_commitment != block_kzg_commitment { + return Err(AvailabilityCheckError::KzgCommitmentMismatch { + blob_commitment: blob.kzg_commitment, + block_commitment: block_kzg_commitment, + }); + } + } + } + AvailableBlockData::DataColumns(data_columns) => { + if !columns_required { + return Err(AvailabilityCheckError::InvalidAvailableBlockData); + } + + let mut column_indices = da_checker + .custody_context + .sampling_columns_for_epoch(block.epoch(), &spec) + .iter() + .collect::<HashSet<_>>(); + + for data_column in data_columns { + column_indices.remove(data_column.index()); + } + + if !column_indices.is_empty() { + return Err(AvailabilityCheckError::MissingCustodyColumns); + } + } } + + Ok(Self { + block_root: block.canonical_root(), + block, + blob_data: block_data, + blobs_available_timestamp: None, + spec: spec.clone(), + }) } pub fn block(&self) -> &SignedBeaconBlock<E> { @@ -801,6 +952,10 @@ impl<E: EthSpec> AvailableBlock<E> { &self.blob_data } + pub fn block_root(&self) -> Hash256 { + self.block_root + } + pub fn has_blobs(&self) -> bool { match self.blob_data { AvailableBlockData::NoData => false, @@ -860,11 +1015,28 @@ impl<E: EthSpec> MaybeAvailableBlock<E> { } } +pub enum MissingCellsError { + /// The provided column is not matching with the existing cached column. + /// This is to be treated as a KZG verification failure. + MismatchesCachedColumn, + /// An error occurred while operating on the column. It is possibly malformed. + /// This is not expected to happen for columns passing basic validation. + UnexpectedError(PartialDataColumnSidecarError), +} + +impl From<PartialDataColumnSidecarError> for MissingCellsError { + fn from(e: PartialDataColumnSidecarError) -> Self { + Self::UnexpectedError(e) + } +} + #[cfg(test)] mod test { use super::*; use crate::CustodyContext; + use crate::block_verification_types::RangeSyncBlock; use crate::custody_context::NodeCustodyType; + use crate::data_column_verification::CustodyDataColumn; use crate::test_utils::{ EphemeralHarnessType, NumBlobs, generate_data_column_indices_rand_order, generate_rand_block_and_data_columns, get_kzg, @@ -875,9 +1047,10 @@ mod test { use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; - use store::HotColdDB; - use types::data_column_sidecar::DataColumn; - use types::{ChainSpec, ColumnIndex, EthSpec, ForkName, MainnetEthSpec, Slot}; + use types::data::DataColumn; + use types::{ + ChainSpec, ColumnIndex, DataColumnSidecarFulu, EthSpec, ForkName, MainnetEthSpec, Slot, + }; type E = MainnetEthSpec; type T = EphemeralHarnessType<E>; @@ -924,15 +1097,23 @@ mod test { &spec, ); let block_root = Hash256::random(); - let custody_columns = custody_context.custody_columns_for_epoch(None, &spec); - let requested_columns = &custody_columns[..10]; + // Get 10 columns using the "latest" CGC (head) that block lookup would use. + // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, + // which is typically epoch 2+ for MinimalEthSpec. + let future_epoch = Epoch::new(10); // Far enough in the future to have the CGC change effective + let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch, &spec); + assert_eq!( + requested_columns.len(), + 10, + "future epoch should have 10 sampling columns" + ); da_checker .put_rpc_custody_columns( block_root, cgc_change_slot, data_columns .into_iter() - .filter(|d| requested_columns.contains(&d.index)) + .filter(|d| requested_columns.contains(d.index())) .collect(), ) .expect("should put rpc custody columns"); @@ -1003,11 +1184,19 @@ mod test { &spec, ); let block_root = Hash256::random(); - let custody_columns = custody_context.custody_columns_for_epoch(None, &spec); - let requested_columns = &custody_columns[..10]; + // Get 10 columns using the "latest" CGC that gossip subscriptions would use. + // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, + // which is typically epoch 2+ for MinimalEthSpec. + let future_epoch = Epoch::new(10); // Far enough in the future to have the CGC change effective + let requested_columns = custody_context.sampling_columns_for_epoch(future_epoch, &spec); + assert_eq!( + requested_columns.len(), + 10, + "future epoch should have 10 sampling columns" + ); let gossip_columns = data_columns .into_iter() - .filter(|d| requested_columns.contains(&d.index)) + .filter(|d| requested_columns.contains(d.index())) .map(GossipVerifiedDataColumn::<T>::__new_for_testing) .collect::<Vec<_>>(); da_checker @@ -1039,7 +1228,7 @@ mod test { /// Regression test for KZG verification truncation bug (https://github.com/sigp/lighthouse/pull/7927) #[test] - fn verify_kzg_for_rpc_blocks_should_not_truncate_data_columns() { + fn verify_kzg_for_range_sync_blocks_should_not_truncate_data_columns_fulu() { let spec = Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())); let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); let da_checker = new_da_checker(spec.clone()); @@ -1057,30 +1246,44 @@ mod test { let custody_columns = if index == 0 { // 128 valid data columns in the first block data_columns - .into_iter() - .map(CustodyDataColumn::from_asserted_custody) - .collect::<Vec<_>>() } else { // invalid data columns in the second block data_columns .into_iter() .map(|d| { - let invalid_sidecar = DataColumnSidecar { + let invalid_sidecar = DataColumnSidecar::Fulu(DataColumnSidecarFulu { column: DataColumn::<E>::empty(), - ..d.as_ref().clone() - }; + index: *d.index(), + kzg_commitments: d.kzg_commitments().unwrap().clone(), + kzg_proofs: d.kzg_proofs().clone(), + signed_block_header: d.signed_block_header().unwrap().clone(), + kzg_commitments_inclusion_proof: d + .kzg_commitments_inclusion_proof() + .unwrap() + .clone(), + }); CustodyDataColumn::from_asserted_custody(Arc::new(invalid_sidecar)) + .as_data_column() + .clone() }) .collect::<Vec<_>>() }; - RpcBlock::new_with_custody_columns(None, Arc::new(block), custody_columns) + let block_data = AvailableBlockData::new_with_data_columns(custody_columns); + let da_checker = Arc::new(new_da_checker(spec.clone())); + RangeSyncBlock::new(Arc::new(block), block_data, &da_checker, spec.clone()) .expect("should create RPC block with custody columns") }) .collect::<Vec<_>>(); + let available_blocks = blocks_with_columns + .into_iter() + .map(|block| block.into_available_block()) + .collect::<Vec<_>>(); + // WHEN verifying all blocks together (totalling 256 data columns) - let verification_result = da_checker.verify_kzg_for_rpc_blocks(blocks_with_columns); + let verification_result = + da_checker.batch_verify_kzg_for_available_blocks(&available_blocks); // THEN batch block verification should fail due to 128 invalid columns in the second block verification_result.expect_err("should have failed to verify blocks"); @@ -1123,10 +1326,10 @@ mod test { // Add 64 columns to the da checker (enough to be able to reconstruct) // Order by all_column_indices_ordered, then take first 64 - let custody_columns = custody_context.custody_columns_for_epoch(None, &spec); + let custody_columns = custody_context.sampling_columns_for_epoch(epoch, &spec); let custody_columns = custody_columns .iter() - .filter_map(|&col_idx| data_columns.iter().find(|d| d.index == col_idx).cloned()) + .filter_map(|&col_idx| data_columns.iter().find(|d| *d.index() == col_idx).cloned()) .take(64) .map(|d| { KzgVerifiedCustodyDataColumn::from_asserted_custody( @@ -1178,10 +1381,9 @@ mod test { let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(0), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); let kzg = get_kzg(&spec); - let store = Arc::new(HotColdDB::open_ephemeral(<_>::default(), spec.clone()).unwrap()); let ordered_custody_column_indices = generate_data_column_indices_rand_order::<E>(); let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, @@ -1193,9 +1395,9 @@ mod test { complete_blob_backfill, slot_clock, kzg, - store, custody_context, spec, + true, ) .expect("should initialise data availability checker") } diff --git a/beacon_node/beacon_chain/src/data_availability_checker/error.rs b/beacon_node/beacon_chain/src/data_availability_checker/error.rs index c9efb7a414..af3cb72c03 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/error.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/error.rs @@ -22,6 +22,7 @@ pub enum Error { BlockReplayError(state_processing::BlockReplayError), RebuildingStateCaches(BeaconStateError), SlotClockError, + InvalidAvailableBlockData, } #[derive(PartialEq, Eq)] @@ -44,7 +45,8 @@ impl Error { | Error::ParentStateMissing(_) | Error::BlockReplayError(_) | Error::RebuildingStateCaches(_) - | Error::SlotClockError => ErrorCategory::Internal, + | Error::SlotClockError + | Error::InvalidAvailableBlockData => ErrorCategory::Internal, Error::InvalidBlobs { .. } | Error::InvalidColumn { .. } | Error::ReconstructColumnsError { .. } diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index 776fb50f61..8f1d4464e1 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -1,7 +1,5 @@ use super::AvailableBlockData; -use super::state_lru_cache::{DietAvailabilityPendingExecutedBlock, StateLRUCache}; use crate::CustodyContext; -use crate::beacon_chain::BeaconStore; use crate::blob_verification::KzgVerifiedBlob; use crate::block_verification_types::{ AvailabilityPendingExecutedBlock, AvailableBlock, AvailableExecutedBlock, @@ -9,7 +7,6 @@ use crate::block_verification_types::{ use crate::data_availability_checker::{Availability, AvailabilityCheckError}; use crate::data_column_verification::KzgVerifiedCustodyDataColumn; use crate::{BeaconChainTypes, BlockProcessStatus}; -use lighthouse_tracing::SPAN_PENDING_COMPONENTS; use lru::LruCache; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; use ssz_types::{RuntimeFixedVector, RuntimeVariableList}; @@ -17,17 +14,16 @@ use std::cmp::Ordering; use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{Span, debug, debug_span}; -use types::beacon_block_body::KzgCommitments; -use types::blob_sidecar::BlobIdentifier; +use types::data::BlobIdentifier; +use types::kzg_ext::KzgCommitments; use types::{ BlobSidecar, BlockImportSource, ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, Epoch, EthSpec, Hash256, SignedBeaconBlock, }; -#[derive(Clone)] pub enum CachedBlock<E: EthSpec> { PreExecution(Arc<SignedBeaconBlock<E>>, BlockImportSource), - Executed(Box<DietAvailabilityPendingExecutedBlock<E>>), + Executed(Box<AvailabilityPendingExecutedBlock<E>>), } impl<E: EthSpec> CachedBlock<E> { @@ -44,7 +40,7 @@ impl<E: EthSpec> CachedBlock<E> { fn as_block(&self) -> &SignedBeaconBlock<E> { match self { CachedBlock::PreExecution(b, _) => b, - CachedBlock::Executed(b) => b.as_block(), + CachedBlock::Executed(b) => b.block.as_ref(), } } @@ -85,14 +81,6 @@ impl<E: EthSpec> PendingComponents<E> { &self.verified_blobs } - #[cfg(test)] - fn get_diet_block(&self) -> Option<&DietAvailabilityPendingExecutedBlock<E>> { - self.block.as_ref().and_then(|block| match block { - CachedBlock::Executed(block) => Some(block.as_ref()), - _ => None, - }) - } - /// Returns an immutable reference to the cached data column. pub fn get_cached_data_column( &self, @@ -130,7 +118,7 @@ impl<E: EthSpec> PendingComponents<E> { } /// Inserts an executed block into the cache. - pub fn insert_executed_block(&mut self, block: DietAvailabilityPendingExecutedBlock<E>) { + pub fn insert_executed_block(&mut self, block: AvailabilityPendingExecutedBlock<E>) { self.block = Some(CachedBlock::Executed(Box::new(block))) } @@ -202,7 +190,7 @@ impl<E: EthSpec> PendingComponents<E> { /// Inserts a new block and revalidates the existing blobs against it. /// /// Blobs that don't match the new block's commitments are evicted. - pub fn merge_block(&mut self, block: DietAvailabilityPendingExecutedBlock<E>) { + pub fn merge_block(&mut self, block: AvailabilityPendingExecutedBlock<E>) { self.insert_executed_block(block); let reinsert = self.get_cached_blobs_mut().take(); self.merge_blobs(reinsert); @@ -210,21 +198,11 @@ impl<E: EthSpec> PendingComponents<E> { /// Returns Some if the block has received all its required data for import. The return value /// must be persisted in the DB along with the block. - /// - /// WARNING: This function can potentially take a lot of time if the state needs to be - /// reconstructed from disk. Ensure you are not holding any write locks while calling this. - pub fn make_available<R>( + pub fn make_available( &self, spec: &Arc<ChainSpec>, num_expected_columns_opt: Option<usize>, - recover: R, - ) -> Result<Option<AvailableExecutedBlock<E>>, AvailabilityCheckError> - where - R: FnOnce( - DietAvailabilityPendingExecutedBlock<E>, - &Span, - ) -> Result<AvailabilityPendingExecutedBlock<E>, AvailabilityCheckError>, - { + ) -> Result<Option<AvailableExecutedBlock<E>>, AvailabilityCheckError> { let Some(CachedBlock::Executed(block)) = &self.block else { // Block not available yet return Ok(None); @@ -267,7 +245,7 @@ impl<E: EthSpec> PendingComponents<E> { ))); } Ordering::Equal => { - let max_blobs = spec.max_blobs_per_block(block.epoch()) as usize; + let max_blobs = spec.max_blobs_per_block(block.block.epoch()) as usize; let blobs_vec = self .verified_blobs .iter() @@ -304,19 +282,22 @@ impl<E: EthSpec> PendingComponents<E> { .flatten() .map(|blob| blob.seen_timestamp()) .max(), - // TODO(das): To be fixed with https://github.com/sigp/lighthouse/pull/6850 - AvailableBlockData::DataColumns(_) => None, + AvailableBlockData::DataColumns(_) => self + .verified_data_columns + .iter() + .map(|data_column| data_column.seen_timestamp()) + .max(), }; let AvailabilityPendingExecutedBlock { block, import_data, payload_verification_outcome, - } = recover(*block.clone(), &self.span)?; + } = block.as_ref(); let available_block = AvailableBlock { block_root: self.block_root, - block, + block: block.clone(), blob_data, blobs_available_timestamp, spec: spec.clone(), @@ -327,14 +308,14 @@ impl<E: EthSpec> PendingComponents<E> { }); Ok(Some(AvailableExecutedBlock::new( available_block, - import_data, - payload_verification_outcome, + import_data.clone(), + payload_verification_outcome.clone(), ))) } /// Returns an empty `PendingComponents` object with the given block root. pub fn empty(block_root: Hash256, max_len: usize) -> Self { - let span = debug_span!(parent: None, SPAN_PENDING_COMPONENTS, %block_root); + let span = debug_span!(parent: None, "lh_pending_components", %block_root); let _guard = span.clone().entered(); Self { block_root, @@ -400,9 +381,6 @@ impl<E: EthSpec> PendingComponents<E> { pub struct DataAvailabilityCheckerInner<T: BeaconChainTypes> { /// Contains all the data we keep in memory, protected by an RwLock critical: RwLock<LruCache<Hash256, PendingComponents<T::EthSpec>>>, - /// This cache holds a limited number of states in memory and reconstructs them - /// from disk when necessary. This is necessary until we merge tree-states - state_cache: StateLRUCache<T>, custody_context: Arc<CustodyContext<T::EthSpec>>, spec: Arc<ChainSpec>, } @@ -419,13 +397,11 @@ pub(crate) enum ReconstructColumnsDecision<E: EthSpec> { impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { pub fn new( capacity: NonZeroUsize, - beacon_store: BeaconStore<T>, custody_context: Arc<CustodyContext<T::EthSpec>>, spec: Arc<ChainSpec>, ) -> Result<Self, AvailabilityCheckError> { Ok(Self { critical: RwLock::new(LruCache::new(capacity)), - state_cache: StateLRUCache::new(beacon_store, spec.clone()), custody_context, spec, }) @@ -442,7 +418,7 @@ impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { BlockProcessStatus::NotValidated(b.clone(), *source) } CachedBlock::Executed(b) => { - BlockProcessStatus::ExecutionValidated(b.block_cloned()) + BlockProcessStatus::ExecutionValidated(b.block.clone()) } }) }) @@ -581,11 +557,9 @@ impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { pending_components: MappedRwLockReadGuard<'_, PendingComponents<T::EthSpec>>, num_expected_columns_opt: Option<usize>, ) -> Result<Availability<T::EthSpec>, AvailabilityCheckError> { - if let Some(available_block) = pending_components.make_available( - &self.spec, - num_expected_columns_opt, - |block, span| self.state_cache.recover_pending_executed_block(block, span), - )? { + if let Some(available_block) = + pending_components.make_available(&self.spec, num_expected_columns_opt)? + { // Explicitly drop read lock before acquiring write lock drop(pending_components); if let Some(components) = self.critical.write().get_mut(&block_root) { @@ -727,6 +701,8 @@ impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { pub fn remove_pre_execution_block(&self, block_root: &Hash256) { // The read lock is immediately dropped so we can safely remove the block from the cache. if let Some(BlockProcessStatus::NotValidated(_, _)) = self.get_cached_block(block_root) { + // If the block is execution invalid, this status is permanent and idempotent to this + // block_root. We drop its components (e.g. columns) because they will never be useful. self.critical.write().pop(block_root); } } @@ -740,14 +716,9 @@ impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { let epoch = executed_block.as_block().epoch(); let block_root = executed_block.import_data.block_root; - // register the block to get the diet block - let diet_executed_block = self - .state_cache - .register_pending_executed_block(executed_block); - let pending_components = self.update_or_insert_pending_components(block_root, epoch, |pending_components| { - pending_components.merge_block(diet_executed_block); + pending_components.merge_block(executed_block); Ok(()) })?; @@ -781,9 +752,6 @@ impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { /// maintain the cache pub fn do_maintenance(&self, cutoff_epoch: Epoch) -> Result<(), AvailabilityCheckError> { - // clean up any lingering states in the state cache - self.state_cache.do_maintenance(cutoff_epoch); - // Collect keys of pending blocks from a previous epoch to cutoff let mut write_lock = self.critical.write(); let mut keys_to_remove = vec![]; @@ -802,17 +770,6 @@ impl<T: BeaconChainTypes> DataAvailabilityCheckerInner<T> { Ok(()) } - #[cfg(test)] - /// get the state cache for inspection (used only for tests) - pub fn state_lru_cache(&self) -> &StateLRUCache<T> { - &self.state_cache - } - - /// Number of states stored in memory in the cache. - pub fn state_cache_size(&self) -> usize { - self.state_cache.lru_cache().read().len() - } - /// Number of pending component entries in memory in the cache. pub fn block_cache_size(&self) -> usize { self.critical.read().len() @@ -829,21 +786,18 @@ mod test { block_verification::PayloadVerificationOutcome, block_verification_types::{AsBlock, BlockImportData}, custody_context::NodeCustodyType, - data_availability_checker::STATE_LRU_CAPACITY_NON_ZERO, test_utils::{BaseHarnessType, BeaconChainHarness, DiskHarnessType}, }; use fork_choice::PayloadVerificationStatus; use logging::create_test_tracing_subscriber; use state_processing::ConsensusContext; - use std::collections::VecDeque; use store::{HotColdDB, ItemStore, StoreConfig, database::interface::BeaconNodeBackend}; use tempfile::{TempDir, tempdir}; - use tracing::{debug_span, info}; - use types::non_zero_usize::new_non_zero_usize; - use types::{ExecPayload, MinimalEthSpec}; + use tracing::info; + use types::MinimalEthSpec; + use types::new_non_zero_usize; const LOW_VALIDATOR_COUNT: usize = 32; - const STATE_LRU_CAPACITY: usize = STATE_LRU_CAPACITY_NON_ZERO.get(); fn get_store_with_spec<E: EthSpec>( db_path: &TempDir, @@ -869,9 +823,8 @@ mod test { async fn get_deneb_chain<E: EthSpec>( db_path: &TempDir, ) -> BeaconChainHarness<DiskHarnessType<E>> { - let altair_fork_epoch = Epoch::new(1); - let bellatrix_fork_epoch = Epoch::new(2); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(E::slots_per_epoch()); + let altair_fork_epoch = Epoch::new(0); + let bellatrix_fork_epoch = Epoch::new(0); let capella_fork_epoch = Epoch::new(3); let deneb_fork_epoch = Epoch::new(4); let deneb_fork_slot = deneb_fork_epoch.start_slot(E::slots_per_epoch()); @@ -893,25 +846,6 @@ mod test { .mock_execution_layer() .build(); - // go to bellatrix slot - harness.extend_to_slot(bellatrix_fork_slot).await; - let bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!(bellatrix_head.as_bellatrix().is_ok()); - assert_eq!(bellatrix_head.slot(), bellatrix_fork_slot); - assert!( - bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Bellatrix head is default payload" - ); - // Trigger the terminal PoW block. - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); // go right before deneb slot harness.extend_to_slot(deneb_fork_slot - 1).await; @@ -991,7 +925,6 @@ mod test { let payload_verification_outcome = PayloadVerificationOutcome { payload_verification_status: PayloadVerificationStatus::Verified, - is_valid_merge_transition_block: false, }; let availability_pending_block = AvailabilityPendingExecutedBlock { @@ -1022,7 +955,6 @@ mod test { let chain_db_path = tempdir().expect("should get temp dir"); let harness = get_deneb_chain(&chain_db_path).await; let spec = harness.spec.clone(); - let test_store = harness.chain.store.clone(); let capacity_non_zero = new_non_zero_usize(capacity); let custody_context = Arc::new(CustodyContext::new( NodeCustodyType::Fullnode, @@ -1032,7 +964,6 @@ mod test { let cache = Arc::new( DataAvailabilityCheckerInner::<T>::new( capacity_non_zero, - test_store, custody_context, spec.clone(), ) @@ -1138,121 +1069,6 @@ mod test { "cache should still have available block" ); } - - #[tokio::test] - // ensure the state cache keeps memory usage low and that it can properly recover states - // THIS TEST CAN BE DELETED ONCE TREE STATES IS MERGED AND WE RIP OUT THE STATE CACHE - async fn overflow_cache_test_state_cache() { - type E = MinimalEthSpec; - type T = DiskHarnessType<E>; - let capacity = STATE_LRU_CAPACITY * 2; - let (harness, cache, _path) = setup_harness_and_cache::<E, T>(capacity).await; - - let mut pending_blocks = VecDeque::new(); - let mut states = Vec::new(); - let mut state_roots = Vec::new(); - // Get enough blocks to fill the cache to capacity, ensuring all blocks have blobs - while pending_blocks.len() < capacity { - let (mut pending_block, _) = availability_pending_block(&harness).await; - if pending_block.num_blobs_expected() == 0 { - // we need blocks with blobs - continue; - } - let state_root = pending_block.import_data.state.canonical_root().unwrap(); - states.push(pending_block.import_data.state.clone()); - pending_blocks.push_back(pending_block); - state_roots.push(state_root); - } - - let state_cache = cache.state_lru_cache().lru_cache(); - let mut pushed_diet_blocks = VecDeque::new(); - - for i in 0..capacity { - let pending_block = pending_blocks.pop_front().expect("should have block"); - let block_root = pending_block.as_block().canonical_root(); - - assert_eq!( - state_cache.read().len(), - std::cmp::min(i, STATE_LRU_CAPACITY), - "state cache should be empty at start" - ); - - if i >= STATE_LRU_CAPACITY { - let lru_root = state_roots[i - STATE_LRU_CAPACITY]; - assert_eq!( - state_cache.read().peek_lru().map(|(root, _)| root), - Some(&lru_root), - "lru block should be in cache" - ); - } - - // put the block in the cache - let availability = cache - .put_executed_block(pending_block) - .expect("should put block"); - - // grab the diet block from the cache for later testing - let diet_block = cache - .critical - .read() - .peek(&block_root) - .and_then(|pending_components| pending_components.get_diet_block().cloned()) - .expect("should exist"); - pushed_diet_blocks.push_back(diet_block); - - // should be unavailable since we made sure all blocks had blobs - assert!( - matches!(availability, Availability::MissingComponents(_)), - "should be pending blobs" - ); - - if i >= STATE_LRU_CAPACITY { - let evicted_index = i - STATE_LRU_CAPACITY; - let evicted_root = state_roots[evicted_index]; - assert!( - state_cache.read().peek(&evicted_root).is_none(), - "lru root should be evicted" - ); - // get the diet block via direct conversion (testing only) - let diet_block = pushed_diet_blocks.pop_front().expect("should have block"); - // reconstruct the pending block by replaying the block on the parent state - let recovered_pending_block = cache - .state_lru_cache() - .recover_pending_executed_block(diet_block, &debug_span!("test")) - .expect("should reconstruct pending block"); - - // assert the recovered state is the same as the original - assert_eq!( - recovered_pending_block.import_data.state, states[evicted_index], - "recovered state should be the same as the original" - ); - } - } - - // now check the last block - let last_block = pushed_diet_blocks.pop_back().expect("should exist").clone(); - // the state should still be in the cache - assert!( - state_cache - .read() - .peek(&last_block.as_block().state_root()) - .is_some(), - "last block state should still be in cache" - ); - // get the diet block via direct conversion (testing only) - let diet_block = last_block.clone(); - // recover the pending block from the cache - let recovered_pending_block = cache - .state_lru_cache() - .recover_pending_executed_block(diet_block, &debug_span!("test")) - .expect("should reconstruct pending block"); - // assert the recovered state is the same as the original - assert_eq!( - Some(&recovered_pending_block.import_data.state), - states.last(), - "recovered state should be the same as the original" - ); - } } #[cfg(test)] @@ -1308,7 +1124,7 @@ mod pending_components_tests { } type PendingComponentsSetup<E> = ( - DietAvailabilityPendingExecutedBlock<E>, + AvailabilityPendingExecutedBlock<E>, RuntimeFixedVector<Option<KzgVerifiedBlob<E>>>, RuntimeFixedVector<Option<KzgVerifiedBlob<E>>>, ); @@ -1349,10 +1165,9 @@ mod pending_components_tests { }, payload_verification_outcome: PayloadVerificationOutcome { payload_verification_status: PayloadVerificationStatus::Verified, - is_valid_merge_transition_block: false, }, }; - (block.into(), blobs, invalid_blobs) + (block, blobs, invalid_blobs) } pub fn assert_cache_consistent(cache: PendingComponents<E>, max_len: usize) { diff --git a/beacon_node/beacon_chain/src/data_availability_checker/state_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/state_lru_cache.rs deleted file mode 100644 index 24f9237e3c..0000000000 --- a/beacon_node/beacon_chain/src/data_availability_checker/state_lru_cache.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crate::block_verification_types::AsBlock; -use crate::{ - AvailabilityPendingExecutedBlock, BeaconChainTypes, BeaconStore, PayloadVerificationOutcome, - block_verification_types::BlockImportData, - data_availability_checker::{AvailabilityCheckError, STATE_LRU_CAPACITY_NON_ZERO}, -}; -use lru::LruCache; -use parking_lot::RwLock; -use state_processing::BlockReplayer; -use std::sync::Arc; -use store::OnDiskConsensusContext; -use tracing::{Span, debug_span, instrument}; -use types::{BeaconState, BlindedPayload, ChainSpec, Epoch, EthSpec, Hash256, SignedBeaconBlock}; - -/// This mirrors everything in the `AvailabilityPendingExecutedBlock`, except -/// that it is much smaller because it contains only a state root instead of -/// a full `BeaconState`. -#[derive(Clone)] -pub struct DietAvailabilityPendingExecutedBlock<E: EthSpec> { - block: Arc<SignedBeaconBlock<E>>, - state_root: Hash256, - parent_block: SignedBeaconBlock<E, BlindedPayload<E>>, - consensus_context: OnDiskConsensusContext<E>, - payload_verification_outcome: PayloadVerificationOutcome, -} - -/// just implementing the same methods as `AvailabilityPendingExecutedBlock` -impl<E: EthSpec> DietAvailabilityPendingExecutedBlock<E> { - pub fn as_block(&self) -> &SignedBeaconBlock<E> { - &self.block - } - - pub fn block_cloned(&self) -> Arc<SignedBeaconBlock<E>> { - self.block.clone() - } - - pub fn num_blobs_expected(&self) -> usize { - self.block - .message() - .body() - .blob_kzg_commitments() - .map_or(0, |commitments| commitments.len()) - } - - /// Returns the epoch corresponding to `self.slot()`. - pub fn epoch(&self) -> Epoch { - self.block.slot().epoch(E::slots_per_epoch()) - } -} - -/// This LRU cache holds BeaconStates used for block import. If the cache overflows, -/// the least recently used state will be dropped. If the dropped state is needed -/// later on, it will be recovered from the parent state and replaying the block. -/// -/// WARNING: This cache assumes the parent block of any `AvailabilityPendingExecutedBlock` -/// has already been imported into ForkChoice. If this is not the case, the cache -/// will fail to recover the state when the cache overflows because it can't load -/// the parent state! -pub struct StateLRUCache<T: BeaconChainTypes> { - states: RwLock<LruCache<Hash256, BeaconState<T::EthSpec>>>, - store: BeaconStore<T>, - spec: Arc<ChainSpec>, -} - -impl<T: BeaconChainTypes> StateLRUCache<T> { - pub fn new(store: BeaconStore<T>, spec: Arc<ChainSpec>) -> Self { - Self { - states: RwLock::new(LruCache::new(STATE_LRU_CAPACITY_NON_ZERO)), - store, - spec, - } - } - - /// This will store the state in the LRU cache and return a - /// `DietAvailabilityPendingExecutedBlock` which is much cheaper to - /// keep around in memory. - pub fn register_pending_executed_block( - &self, - executed_block: AvailabilityPendingExecutedBlock<T::EthSpec>, - ) -> DietAvailabilityPendingExecutedBlock<T::EthSpec> { - let state = executed_block.import_data.state; - let state_root = executed_block.block.state_root(); - self.states.write().put(state_root, state); - - DietAvailabilityPendingExecutedBlock { - block: executed_block.block, - state_root, - parent_block: executed_block.import_data.parent_block, - consensus_context: OnDiskConsensusContext::from_consensus_context( - executed_block.import_data.consensus_context, - ), - payload_verification_outcome: executed_block.payload_verification_outcome, - } - } - - /// Recover the `AvailabilityPendingExecutedBlock` from the diet version. - /// This method will first check the cache and if the state is not found - /// it will reconstruct the state by loading the parent state from disk and - /// replaying the block. - #[instrument(skip_all, parent = _span, level = "debug")] - pub fn recover_pending_executed_block( - &self, - diet_executed_block: DietAvailabilityPendingExecutedBlock<T::EthSpec>, - _span: &Span, - ) -> Result<AvailabilityPendingExecutedBlock<T::EthSpec>, AvailabilityCheckError> { - // Keep the state in the cache to prevent reconstruction in race conditions - let state = if let Some(state) = self.states.write().get(&diet_executed_block.state_root) { - state.clone() - } else { - self.reconstruct_state(&diet_executed_block)? - }; - let block_root = diet_executed_block.block.canonical_root(); - Ok(AvailabilityPendingExecutedBlock { - block: diet_executed_block.block, - import_data: BlockImportData { - block_root, - state, - parent_block: diet_executed_block.parent_block, - consensus_context: diet_executed_block - .consensus_context - .into_consensus_context(), - }, - payload_verification_outcome: diet_executed_block.payload_verification_outcome, - }) - } - - /// Reconstruct the state by loading the parent state from disk and replaying - /// the block. - #[instrument(skip_all, level = "debug")] - fn reconstruct_state( - &self, - diet_executed_block: &DietAvailabilityPendingExecutedBlock<T::EthSpec>, - ) -> Result<BeaconState<T::EthSpec>, AvailabilityCheckError> { - let parent_block_root = diet_executed_block.parent_block.canonical_root(); - let parent_block_state_root = diet_executed_block.parent_block.state_root(); - let (parent_state_root, parent_state) = self - .store - .get_advanced_hot_state( - parent_block_root, - diet_executed_block.parent_block.slot(), - parent_block_state_root, - ) - .map_err(AvailabilityCheckError::StoreError)? - .ok_or(AvailabilityCheckError::ParentStateMissing( - parent_block_state_root, - ))?; - - let state_roots = vec![ - Ok((parent_state_root, diet_executed_block.parent_block.slot())), - Ok(( - diet_executed_block.state_root, - diet_executed_block.block.slot(), - )), - ]; - - let block_replayer: BlockReplayer<'_, T::EthSpec, AvailabilityCheckError, _> = - BlockReplayer::new(parent_state, &self.spec) - .no_signature_verification() - .state_root_iter(state_roots.into_iter()) - .minimal_block_root_verification(); - - let block_replayer = debug_span!("reconstruct_state_apply_blocks").in_scope(|| { - block_replayer.apply_blocks(vec![diet_executed_block.block.clone_as_blinded()], None) - }); - - block_replayer - .map(|block_replayer| block_replayer.into_state()) - .and_then(|mut state| { - state - .build_exit_cache(&self.spec) - .map_err(AvailabilityCheckError::RebuildingStateCaches)?; - state - .update_tree_hash_cache() - .map_err(AvailabilityCheckError::RebuildingStateCaches)?; - Ok(state) - }) - } - - /// returns the state cache for inspection - pub fn lru_cache(&self) -> &RwLock<LruCache<Hash256, BeaconState<T::EthSpec>>> { - &self.states - } - - /// remove any states from the cache from before the given epoch - pub fn do_maintenance(&self, cutoff_epoch: Epoch) { - let mut write_lock = self.states.write(); - while let Some((_, state)) = write_lock.peek_lru() { - if state.slot().epoch(T::EthSpec::slots_per_epoch()) < cutoff_epoch { - write_lock.pop_lru(); - } else { - break; - } - } - } -} - -/// This can only be used during testing. The intended way to -/// obtain a `DietAvailabilityPendingExecutedBlock` is to call -/// `register_pending_executed_block` on the `StateLRUCache`. -#[cfg(test)] -impl<E: EthSpec> From<AvailabilityPendingExecutedBlock<E>> - for DietAvailabilityPendingExecutedBlock<E> -{ - fn from(mut value: AvailabilityPendingExecutedBlock<E>) -> Self { - Self { - block: value.block, - state_root: value.import_data.state.canonical_root().unwrap(), - parent_block: value.import_data.parent_block, - consensus_context: OnDiskConsensusContext::from_consensus_context( - value.import_data.consensus_context, - ), - payload_verification_outcome: value.payload_verification_outcome, - } - } -} diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index b998602566..8ea3c792f4 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -1,29 +1,40 @@ use crate::block_verification::{ BlockSlashInfo, get_validator_pubkey_cache, process_block_slash_info, }; -use crate::kzg_utils::{reconstruct_data_columns, validate_data_columns}; -use crate::observed_data_sidecars::{ObservationStrategy, Observe}; +use crate::data_availability_checker::MissingCellsError; +use crate::kzg_utils::{ + reconstruct_data_columns, validate_full_data_columns, validate_partial_data_columns, +}; +use crate::observed_data_sidecars::{ + Error as ObservedDataSidecarsError, ObservationKey, ObservationStrategy, Observe, +}; use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; use educe::Educe; use fork_choice::ProtoBlock; use kzg::{Error as KzgError, Kzg}; use proto_array::Block; -use slot_clock::SlotClock; -use ssz_derive::{Decode, Encode}; +use slot_clock::{SlotClock, timestamp_now}; +use ssz_derive::Encode; use ssz_types::VariableList; use std::iter; use std::marker::PhantomData; use std::sync::Arc; +use std::time::Duration; use tracing::{debug, instrument}; -use types::data_column_sidecar::ColumnIndex; +use tree_hash::TreeHash; +use types::data::{ + ColumnIndex, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnSidecar, + PartialDataColumnSidecarError, +}; use types::{ - BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSubnetId, EthSpec, Hash256, - SignedBeaconBlockHeader, Slot, + BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, + EthSpec, Hash256, PartialDataColumnSidecarRef, SignedBeaconBlockHeader, Slot, }; /// An error occurred while validating a gossip data column. #[derive(Debug)] pub enum GossipDataColumnError { + InvalidVariant, /// There was an error whilst processing the data column. It is not known if it is /// valid or invalid. /// @@ -59,12 +70,22 @@ pub enum GossipDataColumnError { /// /// The data column sidecar is invalid and the peer is faulty. InvalidKzgProof(kzg::Error), + /// The column mismatches the cached (possibly partial) column. + /// This is equivalent to failed kzg verification. + /// + /// ## Peer scoring + /// + /// The data column sidecar is invalid and the peer is faulty. + MismatchesCachedColumn, /// The column was gossiped over an incorrect subnet. /// /// ## Peer scoring /// /// The column is invalid or the peer is faulty. - InvalidSubnetId { received: u64, expected: u64 }, + InvalidSubnetId { + received: u64, + expected: u64, + }, /// The column sidecar is from a slot that is later than the current slot (with respect to the /// gossip clock disparity). /// @@ -97,35 +118,41 @@ pub enum GossipDataColumnError { /// ## Peer scoring /// /// The column is invalid and the peer is faulty. - ProposerIndexMismatch { sidecar: usize, local: usize }, + ProposerIndexMismatch { + sidecar: usize, + local: usize, + }, /// The provided columns's parent block is unknown. /// /// ## Peer scoring /// /// We cannot process the columns without validating its parent, the peer isn't necessarily faulty. - ParentUnknown { parent_root: Hash256 }, + ParentUnknown { + parent_root: Hash256, + slot: Slot, + }, /// The column conflicts with finalization, no need to propagate. /// /// ## Peer scoring /// /// It's unclear if this column is valid, but it conflicts with finality and shouldn't be /// imported. - NotFinalizedDescendant { block_parent_root: Hash256 }, + NotFinalizedDescendant { + block_parent_root: Hash256, + }, /// Invalid kzg commitment inclusion proof /// /// ## Peer scoring /// /// The column sidecar is invalid and the peer is faulty InvalidInclusionProof, - /// A column has already been seen for the given `(sidecar.block_root, sidecar.index)` tuple - /// over gossip or no gossip sources. + /// A column has already been seen for the given observation key and index. /// /// ## Peer scoring /// /// The peer isn't faulty, but we do not forward it over gossip. PriorKnown { - proposer: u64, - slot: Slot, + observation_key: ObservationKey, index: ColumnIndex, }, /// A column has already been processed from non-gossip source and have not yet been seen on @@ -160,7 +187,10 @@ pub enum GossipDataColumnError { /// ## Peer scoring /// /// The column sidecar is invalid and the peer is faulty - InconsistentProofsLength { cells_len: usize, proofs_len: usize }, + InconsistentProofsLength { + cells_len: usize, + proofs_len: usize, + }, /// The number of KZG commitments exceeds the maximum number of blobs allowed for the fork. The /// sidecar is invalid. /// @@ -184,42 +214,114 @@ impl From<BeaconStateError> for GossipDataColumnError { } } +#[derive(Debug)] +pub enum GossipPartialDataColumnError { + GossipDataColumnError(GossipDataColumnError), + /// Partial messages are disabled and we can not validate them. + /// + /// ## Peer scoring + /// A peer sent us a partial message even though we did not advertize support for it, penalize + /// it + PartialColumnsDisabled, + /// There was an unexpected error while performing an operation on the partial data column. + InternalError(PartialDataColumnSidecarError), + /// The partial data column does not contain a header, and we do not have it cached. + /// + /// ## Peer scoring + /// The peer SHOULD send us the header on the first partial message, but is not required to. + /// Still, the peer incorrectly assumed that we have the header, and sent us data we can not + /// process due to that. Penalize it slightly. + MissingHeader, + /// The partial data column header does not match the valid one we have already cached. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + HeaderMismatches, + /// The partial data column header block root does not match the group id. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + HeaderIncorrectRoot { + group_id: Hash256, + header_hash: Hash256, + }, + /// The partial message has neither a header nor cells. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + EmptyMessage, + /// The partial message has a count of proofs anc/or cells that is inconsistent with the bitmap. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + InconsistentPresentCount { + bitmap_popcount: usize, + cells_len: usize, + proofs_len: usize, + }, + /// The partial message has a bitmap length that is inconsistent with the number of commitments. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + InconsistentCommitmentsLength { + bitmap_len: usize, + commitments_len: usize, + }, +} + +impl From<GossipDataColumnError> for GossipPartialDataColumnError { + fn from(e: GossipDataColumnError) -> Self { + GossipPartialDataColumnError::GossipDataColumnError(e) + } +} + +impl From<BeaconChainError> for GossipPartialDataColumnError { + fn from(e: BeaconChainError) -> Self { + GossipDataColumnError::from(e).into() + } +} + +impl From<BeaconStateError> for GossipPartialDataColumnError { + fn from(e: BeaconStateError) -> Self { + GossipDataColumnError::from(e).into() + } +} + /// A wrapper around a `DataColumnSidecar` that indicates it has been approved for re-gossiping on /// the p2p network. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GossipVerifiedDataColumn<T: BeaconChainTypes, O: ObservationStrategy = Observe> { block_root: Hash256, data_column: KzgVerifiedDataColumn<T::EthSpec>, _phantom: PhantomData<O>, } -impl<T: BeaconChainTypes, O: ObservationStrategy> Clone for GossipVerifiedDataColumn<T, O> { - fn clone(&self) -> Self { - Self { - block_root: self.block_root, - data_column: self.data_column.clone(), - _phantom: PhantomData, - } - } -} - impl<T: BeaconChainTypes, O: ObservationStrategy> GossipVerifiedDataColumn<T, O> { pub fn new( column_sidecar: Arc<DataColumnSidecar<T::EthSpec>>, subnet_id: DataColumnSubnetId, chain: &BeaconChain<T>, ) -> Result<Self, GossipDataColumnError> { - let header = column_sidecar.signed_block_header.clone(); - // We only process slashing info if the gossip verification failed - // since we do not process the data column any further in that case. - validate_data_column_sidecar_for_gossip::<T, O>(column_sidecar, subnet_id, chain).map_err( - |e| { - process_block_slash_info::<_, GossipDataColumnError>( + match column_sidecar.as_ref() { + DataColumnSidecar::Fulu(c) => { + let header = c.signed_block_header.clone(); + // We only process slashing info if the gossip verification failed + // since we do not process the data column any further in that case. + validate_data_column_sidecar_for_gossip_fulu::<T, O>( + column_sidecar, + subnet_id, chain, - BlockSlashInfo::from_early_error_data_column(header, e), ) - }, - ) + .map_err(|e| { + process_block_slash_info::<_, GossipDataColumnError>( + chain, + BlockSlashInfo::from_early_error_data_column(header, e), + ) + }) + } + // TODO(gloas) support gloas data column variant + DataColumnSidecar::Gloas(_) => Err(GossipDataColumnError::InvalidVariant), + } } /// Create a `GossipVerifiedDataColumn` from `DataColumnSidecar` for block production ONLY. @@ -238,22 +340,29 @@ impl<T: BeaconChainTypes, O: ObservationStrategy> GossipVerifiedDataColumn<T, O> // In this case, we should accept it for gossip propagation. verify_is_unknown_sidecar(chain, &column_sidecar)?; - if chain + match chain .data_availability_checker - .is_data_column_cached(&column_sidecar.block_root(), &column_sidecar) + .missing_cells_for_column_sidecar(&column_sidecar) { - // Observe this data column so we don't process it again. - if O::observe() { - observe_gossip_data_column(&column_sidecar, chain)?; + Ok(Some(_)) => Ok(Self { + block_root: column_sidecar.block_root(), + data_column: KzgVerifiedDataColumn::from_execution_verified(column_sidecar), + _phantom: Default::default(), + }), + Ok(None) => { + // Observe this data column so we don't process it again. + if O::observe() { + observe_gossip_data_column(&column_sidecar, chain)?; + } + Err(GossipDataColumnError::PriorKnownUnpublished) + } + Err(MissingCellsError::MismatchesCachedColumn) => { + Err(GossipDataColumnError::MismatchesCachedColumn) + } + Err(MissingCellsError::UnexpectedError(_)) => { + todo!("handle unexpected error") } - return Err(GossipDataColumnError::PriorKnownUnpublished); } - - Ok(Self { - block_root: column_sidecar.block_root(), - data_column: KzgVerifiedDataColumn::from_execution_verified(column_sidecar), - _phantom: Default::default(), - }) } /// Create a `GossipVerifiedDataColumn` from `DataColumnSidecar` for testing ONLY. @@ -283,11 +392,7 @@ impl<T: BeaconChainTypes, O: ObservationStrategy> GossipVerifiedDataColumn<T, O> } pub fn index(&self) -> ColumnIndex { - self.data_column.data.index - } - - pub fn signed_block_header(&self) -> SignedBeaconBlockHeader { - self.data_column.data.signed_block_header.clone() + *self.data_column.data.index() } pub fn into_inner(self) -> KzgVerifiedDataColumn<T::EthSpec> { @@ -296,30 +401,29 @@ impl<T: BeaconChainTypes, O: ObservationStrategy> GossipVerifiedDataColumn<T, O> } /// Wrapper over a `DataColumnSidecar` for which we have completed kzg verification. -#[derive(Debug, Educe, Clone, Encode, Decode)] +#[derive(Debug, Educe, Clone)] #[educe(PartialEq, Eq)] -#[ssz(struct_behaviour = "transparent")] pub struct KzgVerifiedDataColumn<E: EthSpec> { data: Arc<DataColumnSidecar<E>>, + seen_timestamp: Duration, } impl<E: EthSpec> KzgVerifiedDataColumn<E> { - pub fn new( - data_column: Arc<DataColumnSidecar<E>>, - kzg: &Kzg, - ) -> Result<Self, (Option<ColumnIndex>, KzgError)> { - verify_kzg_for_data_column(data_column, kzg) - } - /// Mark a data column as KZG verified. Caller must ONLY use this on columns constructed /// from EL blobs. pub fn from_execution_verified(data_column: Arc<DataColumnSidecar<E>>) -> Self { - Self { data: data_column } + Self { + data: data_column, + seen_timestamp: timestamp_now(), + } } /// Create a `KzgVerifiedDataColumn` from `DataColumnSidecar` for testing ONLY. pub(crate) fn __new_for_testing(data_column: Arc<DataColumnSidecar<E>>) -> Self { - Self { data: data_column } + Self { + data: data_column, + seen_timestamp: timestamp_now(), + } } pub fn from_batch_with_scoring( @@ -329,7 +433,10 @@ impl<E: EthSpec> KzgVerifiedDataColumn<E> { verify_kzg_for_data_column_list(data_columns.iter(), kzg)?; Ok(data_columns .into_iter() - .map(|column| Self { data: column }) + .map(|column| Self { + data: column, + seen_timestamp: timestamp_now(), + }) .collect()) } @@ -344,16 +451,141 @@ impl<E: EthSpec> KzgVerifiedDataColumn<E> { self.data.clone() } + pub fn index(&self) -> ColumnIndex { + *self.data.index() + } +} + +/// Wrapper over a `VerifiablePartialDataColumn` for which we have completed kzg verification. +#[derive(Debug, Educe, Clone)] +#[educe(PartialEq, Eq)] +pub struct KzgVerifiedPartialDataColumn<E: EthSpec> { + data: Arc<PartialDataColumn<E>>, + latest_cell_timestamp: Duration, +} + +impl<E: EthSpec> KzgVerifiedPartialDataColumn<E> { + /// Create a `KzgVerifiedPartialDataColumn` for testing ONLY. + pub(crate) fn __new_for_testing(data_column: Arc<PartialDataColumn<E>>) -> Self { + Self { + data: data_column, + latest_cell_timestamp: timestamp_now(), + } + } + + /// Mark a partial data column as KZG verified. Caller must ONLY use this on columns constructed + /// from EL blobs. + pub fn from_execution_verified(data_column: Arc<PartialDataColumn<E>>) -> Self { + Self { + data: data_column, + latest_cell_timestamp: timestamp_now(), + } + } + + pub fn to_data_column(self) -> Arc<PartialDataColumn<E>> { + self.data + } + + pub fn as_data_column(&self) -> &PartialDataColumn<E> { + &self.data + } + pub fn index(&self) -> ColumnIndex { self.data.index } + + pub fn block_root(&self) -> Hash256 { + self.data.block_root + } +} + +/// Wrapper over a `PartialDataColumnHeader` for which we have completed gossip verification. +#[derive(Debug, Educe, Clone)] +#[educe(PartialEq, Eq)] +pub struct GossipVerifiedPartialDataColumnHeader<E: EthSpec> { + header: Arc<PartialDataColumnHeader<E>>, + previously_cached: bool, +} + +impl<E: EthSpec> GossipVerifiedPartialDataColumnHeader<E> { + pub fn new<T: BeaconChainTypes<EthSpec = E>>( + group_id: Hash256, + header: PartialDataColumnHeader<E>, + chain: &BeaconChain<T>, + ) -> Result<Self, GossipPartialDataColumnError> { + let column_slot = header.slot(); + if header.kzg_commitments.is_empty() { + return Err(GossipDataColumnError::UnexpectedDataColumn.into()); + } + + let header_hash = header.signed_block_header.message.canonical_root(); + if group_id != header_hash { + return Err(GossipPartialDataColumnError::HeaderIncorrectRoot { + group_id, + header_hash, + }); + } + + verify_sidecar_not_from_future_slot(chain, column_slot)?; + verify_slot_greater_than_latest_finalized_slot(chain, column_slot)?; + verify_partial_column_header_inclusion_proof(&header)?; + let parent_block = verify_parent_block_and_finalized_descendant( + header.signed_block_header.message.parent_root, + column_slot, + chain, + )?; + verify_slot_higher_than_parent(&parent_block, column_slot)?; + verify_proposer_and_signature(&header.signed_block_header, &parent_block, chain)?; + + let header = Arc::new(header); + + // Cache the valid header + let Some(assembler) = chain.data_availability_checker.partial_assembler() else { + return Err(GossipPartialDataColumnError::PartialColumnsDisabled); + }; + let newly_cached = assembler.init(group_id, header.clone()); + + chain + .observed_slashable + .write() + .observe_slashable( + column_slot, + header.signed_block_header.message.proposer_index, + header_hash, + ) + .map_err(BeaconChainError::from)?; + + Ok(Self { + header, + previously_cached: !newly_cached, + }) + } + + pub fn new_from_cached(header: Arc<PartialDataColumnHeader<E>>) -> Self { + Self { + header, + previously_cached: true, + } + } + + pub fn was_cached(&self) -> bool { + self.previously_cached + } + + pub fn as_header(&self) -> &PartialDataColumnHeader<E> { + &self.header + } + + pub fn into_header(self) -> Arc<PartialDataColumnHeader<E>> { + self.header + } } pub type CustodyDataColumnList<E> = VariableList<CustodyDataColumn<E>, <E as EthSpec>::NumberOfColumns>; /// Data column that we must custody -#[derive(Debug, Educe, Clone, Encode, Decode)] +#[derive(Debug, Educe, Clone, Encode)] #[educe(PartialEq, Eq, Hash(bound(E: EthSpec)))] #[ssz(struct_behaviour = "transparent")] pub struct CustodyDataColumn<E: EthSpec> { @@ -378,16 +610,17 @@ impl<E: EthSpec> CustodyDataColumn<E> { self.data.clone() } pub fn index(&self) -> u64 { - self.data.index + *self.data.index() } } -/// Data column that we must custody and has completed kzg verification -#[derive(Debug, Educe, Clone, Encode, Decode)] +/// Data column that we must custody and has completed kzg verification. +/// Wraps a full `DataColumnSidecar`. +#[derive(Debug, Educe, Clone)] #[educe(PartialEq, Eq)] -#[ssz(struct_behaviour = "transparent")] pub struct KzgVerifiedCustodyDataColumn<E: EthSpec> { data: Arc<DataColumnSidecar<E>>, + seen_timestamp: Duration, } impl<E: EthSpec> KzgVerifiedCustodyDataColumn<E> { @@ -395,21 +628,11 @@ impl<E: EthSpec> KzgVerifiedCustodyDataColumn<E> { /// include this column pub fn from_asserted_custody(kzg_verified: KzgVerifiedDataColumn<E>) -> Self { Self { + seen_timestamp: kzg_verified.seen_timestamp, data: kzg_verified.to_data_column(), } } - /// Verify a column already marked as custody column - pub fn new( - data_column: CustodyDataColumn<E>, - kzg: &Kzg, - ) -> Result<Self, (Option<ColumnIndex>, KzgError)> { - verify_kzg_for_data_column(data_column.clone_arc(), kzg)?; - Ok(Self { - data: data_column.data, - }) - } - pub fn reconstruct_columns( kzg: &Kzg, partial_set_of_columns: &[Self], @@ -424,10 +647,15 @@ impl<E: EthSpec> KzgVerifiedCustodyDataColumn<E> { spec, )?; + let seen_timestamp = timestamp_now(); + Ok(all_data_columns .into_iter() .map(|data| { - KzgVerifiedCustodyDataColumn::from_asserted_custody(KzgVerifiedDataColumn { data }) + KzgVerifiedCustodyDataColumn::from_asserted_custody(KzgVerifiedDataColumn { + data, + seen_timestamp, + }) }) .collect::<Vec<_>>()) } @@ -442,9 +670,166 @@ impl<E: EthSpec> KzgVerifiedCustodyDataColumn<E> { pub fn clone_arc(&self) -> Arc<DataColumnSidecar<E>> { self.data.clone() } + pub fn index(&self) -> ColumnIndex { + *self.data.index() + } + + pub fn seen_timestamp(&self) -> Duration { + self.seen_timestamp + } +} + +/// Partial data column that we must custody and has completed kzg verification. +/// Wraps a `VerifiablePartialDataColumn`. +#[derive(Debug, Educe, Clone)] +#[educe(PartialEq, Eq)] +pub struct KzgVerifiedCustodyPartialDataColumn<E: EthSpec> { + data: Arc<PartialDataColumn<E>>, + latest_cell_timestamp: Duration, +} + +impl<E: EthSpec> KzgVerifiedCustodyPartialDataColumn<E> { + /// Mark a partial column as custody column. Caller must ensure that our current custody requirements + /// include this column + pub fn from_asserted_custody(kzg_verified: KzgVerifiedPartialDataColumn<E>) -> Self { + Self { + latest_cell_timestamp: kzg_verified.latest_cell_timestamp, + data: kzg_verified.to_data_column(), + } + } + + pub fn into_inner(self) -> Arc<PartialDataColumn<E>> { + self.data + } + + pub fn as_data_column(&self) -> &PartialDataColumn<E> { + &self.data + } + pub fn index(&self) -> ColumnIndex { self.data.index } + + /// Merge two verified partial data columns. + /// + /// Each column must be internally consistent. Additionally, the columns to be merged must have + /// the same block root and index. + /// An error is returned if the columns are internally inconsistent or incompatible for merging. + /// + /// If both columns contain the same cell, the cell from `self` is used - however, as they are + /// KZG verified, they will be the same. + pub fn merge(&self, other: &Self) -> Result<Self, PartialDataColumnSidecarError> { + let self_sidecar = &self.data.sidecar; + let other_sidecar = &other.data.sidecar; + + // Check that each sidecar is internally consistent by checking the lengths. + self_sidecar.verify_len()?; + other_sidecar.verify_len()?; + if self.data.block_root != other.data.block_root || self.data.index != other.data.index { + return Err(PartialDataColumnSidecarError::ConflictingData); + } + if self_sidecar.cells_present_bitmap.len() != other_sidecar.cells_present_bitmap.len() { + return Err(PartialDataColumnSidecarError::DifferingLengths { + lhs_len: self_sidecar.cells_present_bitmap.len(), + rhs_len: other_sidecar.cells_present_bitmap.len(), + }); + } + + let new_bitmap = self_sidecar + .cells_present_bitmap + .union(&other_sidecar.cells_present_bitmap); + let len = new_bitmap.num_set_bits(); + let mut new_column = Vec::with_capacity(len); + let mut new_proofs = Vec::with_capacity(len); + let mut self_iter = self_sidecar + .column + .iter() + .zip(self_sidecar.kzg_proofs.iter()); + let mut other_iter = other_sidecar + .column + .iter() + .zip(other_sidecar.kzg_proofs.iter()); + + for presence_bits in self_sidecar + .cells_present_bitmap + .iter() + .zip(other_sidecar.cells_present_bitmap.iter()) + { + match presence_bits { + (false, false) => {} + (true, other) => { + let (cell, proof) = self_iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + new_column.push(cell.clone()); + new_proofs.push(*proof); + if other { + other_iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + } + } + (false, true) => { + let (cell, proof) = other_iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + new_column.push(cell.clone()); + new_proofs.push(*proof); + } + } + } + + Ok(Self { + data: Arc::new(PartialDataColumn { + block_root: self.data.block_root, + index: self.data.index, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: new_bitmap, + column: new_column + .try_into() + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?, + kzg_proofs: new_proofs + .try_into() + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?, + header: if self_sidecar.header.is_some() { + self_sidecar.header.clone() + } else { + other_sidecar.header.clone() + }, + }, + }), + latest_cell_timestamp: self.latest_cell_timestamp.max(other.latest_cell_timestamp), + }) + } + + pub fn try_clone_full( + &self, + header: &PartialDataColumnHeader<E>, + ) -> Option<KzgVerifiedCustodyDataColumn<E>> { + self.data + .try_clone_full(header) + .map(|data| KzgVerifiedCustodyDataColumn { + data: Arc::new(data), + seen_timestamp: self.latest_cell_timestamp, + }) + } + + /// Try to convert the partial data column into a full one, returning None if the conversion + /// fails. + /// May clone the column if the Arc cannot be unwrapped. + pub fn try_into_full( + self, + header: &PartialDataColumnHeader<E>, + ) -> Option<KzgVerifiedCustodyDataColumn<E>> { + match Arc::try_unwrap(self.data) { + Ok(data) => data.try_into_full(header), + Err(data) => data.try_clone_full(header), + } + .map(|data| KzgVerifiedCustodyDataColumn { + data: Arc::new(data), + seen_timestamp: self.latest_cell_timestamp, + }) + } } /// Complete kzg verification for a `DataColumnSidecar`. @@ -453,11 +838,50 @@ impl<E: EthSpec> KzgVerifiedCustodyDataColumn<E> { #[instrument(skip_all, level = "debug")] pub fn verify_kzg_for_data_column<E: EthSpec>( data_column: Arc<DataColumnSidecar<E>>, + cells_to_verify: PartialDataColumnSidecarRef<E>, kzg: &Kzg, + seen_timestamp: Duration, ) -> Result<KzgVerifiedDataColumn<E>, (Option<ColumnIndex>, KzgError)> { let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_SINGLE_TIMES); - validate_data_columns(kzg, iter::once(&data_column))?; - Ok(KzgVerifiedDataColumn { data: data_column }) + let Ok(kzg_commitments) = data_column.kzg_commitments() else { + return Err(( + Some(*data_column.index()), + KzgError::InconsistentArrayLength("todo(gloas)".to_string()), + )); + }; + validate_partial_data_columns( + kzg, + iter::once((*data_column.index(), cells_to_verify)), + kzg_commitments, + )?; + Ok(KzgVerifiedDataColumn { + data: data_column, + seen_timestamp, + }) +} + +/// Complete kzg verification for a `VerifiablePartialDataColumn`. +/// +/// Returns an error if the kzg verification check fails. +#[instrument(skip_all, level = "debug")] +pub fn verify_kzg_for_partial_data_column<E: EthSpec>( + data_column: Arc<PartialDataColumn<E>>, + cells_to_verify: PartialDataColumnSidecarRef<E>, + header: &GossipVerifiedPartialDataColumnHeader<E>, + kzg: &Kzg, + seen_timestamp: Duration, +) -> Result<KzgVerifiedPartialDataColumn<E>, GossipPartialDataColumnError> { + let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_SINGLE_TIMES); + validate_partial_data_columns( + kzg, + iter::once((data_column.index, cells_to_verify)), + header.header.kzg_commitments.as_ref(), + ) + .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; + Ok(KzgVerifiedPartialDataColumn { + data: data_column, + latest_cell_timestamp: seen_timestamp, + }) } /// Complete kzg verification for a list of `DataColumnSidecar`s. @@ -473,16 +897,24 @@ where I: Iterator<Item = &'a Arc<DataColumnSidecar<E>>> + Clone, { let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_BATCH_TIMES); - validate_data_columns(kzg, data_column_iter)?; + validate_full_data_columns(kzg, data_column_iter)?; Ok(()) } -#[instrument(skip_all, level = "debug")] -pub fn validate_data_column_sidecar_for_gossip<T: BeaconChainTypes, O: ObservationStrategy>( +#[instrument( + skip_all, + name = "validate_data_column_sidecar_for_gossip", + level = "debug" +)] +pub fn validate_data_column_sidecar_for_gossip_fulu<T: BeaconChainTypes, O: ObservationStrategy>( data_column: Arc<DataColumnSidecar<T::EthSpec>>, subnet: DataColumnSubnetId, chain: &BeaconChain<T>, ) -> Result<GossipVerifiedDataColumn<T, O>, GossipDataColumnError> { + let DataColumnSidecar::Fulu(data_column_fulu) = data_column.as_ref() else { + return Err(GossipDataColumnError::InvalidVariant); + }; + let column_slot = data_column.slot(); verify_data_column_sidecar(&data_column, &chain.spec)?; verify_index_matches_subnet(&data_column, subnet, &chain.spec)?; @@ -491,35 +923,52 @@ pub fn validate_data_column_sidecar_for_gossip<T: BeaconChainTypes, O: Observati verify_is_unknown_sidecar(chain, &data_column)?; // Check if the data column is already in the DA checker cache. This happens when data columns - // are made available through the `engine_getBlobs` method. If it exists in the cache, we know - // it has already passed the gossip checks, even though this particular instance hasn't been - // seen / published on the gossip network yet (passed the `verify_is_unknown_sidecar` check above). - // In this case, we should accept it for gossip propagation. - if chain + // are made available through the `engine_getBlobs` method and/or partial messages have arrived. + // If it exists in the cache, we know it has already passed the gossip checks, even though this + // particular instance hasn't been seen / published on the gossip network yet (passed the + // `verify_is_unknown_sidecar` check above). In this case, we should accept it for gossip + // propagation. + let Some(cells_to_kzg_verify) = chain .data_availability_checker - .is_data_column_cached(&data_column.block_root(), &data_column) - { + .missing_cells_for_column_sidecar(&data_column) + .map_err(|err| match err { + MissingCellsError::MismatchesCachedColumn => { + GossipDataColumnError::MismatchesCachedColumn + } + MissingCellsError::UnexpectedError(_) => todo!("handle unexpected error"), + })? + else { // Observe this data column so we don't process it again. if O::observe() { observe_gossip_data_column(&data_column, chain)?; } return Err(GossipDataColumnError::PriorKnownUnpublished); - } + }; - verify_column_inclusion_proof(&data_column)?; - let parent_block = verify_parent_block_and_finalized_descendant(data_column.clone(), chain)?; + verify_column_inclusion_proof(data_column_fulu)?; + let parent_block = verify_parent_block_and_finalized_descendant( + data_column_fulu.block_parent_root(), + column_slot, + chain, + )?; verify_slot_higher_than_parent(&parent_block, column_slot)?; - verify_proposer_and_signature(&data_column, &parent_block, chain)?; + verify_proposer_and_signature(&data_column_fulu.signed_block_header, &parent_block, chain)?; let kzg = &chain.kzg; - let kzg_verified_data_column = verify_kzg_for_data_column(data_column.clone(), kzg) - .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); + let kzg_verified_data_column = verify_kzg_for_data_column( + data_column.clone(), + cells_to_kzg_verify, + kzg, + seen_timestamp, + ) + .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; chain .observed_slashable .write() .observe_slashable( column_slot, - data_column.block_proposer_index(), + data_column_fulu.block_proposer_index(), data_column.block_root(), ) .map_err(|e| GossipDataColumnError::BeaconChainError(Box::new(e.into())))?; @@ -535,21 +984,160 @@ pub fn validate_data_column_sidecar_for_gossip<T: BeaconChainTypes, O: Observati }) } +#[instrument(skip_all, level = "debug")] +pub fn validate_partial_data_column_sidecar_for_gossip<T: BeaconChainTypes>( + mut column: Box<PartialDataColumn<T::EthSpec>>, + chain: &BeaconChain<T>, + seen_timestamp: Duration, +) -> PartialColumnVerificationResult<T::EthSpec> { + let block_root = column.block_root; + + // Remove the header (if any) to avoid wasted memory. + let header = column.sidecar.header.take(); + + let header = if let Some(header) = header { + // Header was sent, so it is required to be valid + match chain.verify_partial_data_column_header_for_gossip(block_root, header) { + Ok(verified) => verified, + Err(err) => { + return PartialColumnVerificationResult::Err(err); + } + } + } else { + let Some(assembler) = chain.data_availability_checker.partial_assembler() else { + return PartialColumnVerificationResult::Err( + GossipPartialDataColumnError::PartialColumnsDisabled, + ); + }; + + // There is no header, so we check if we have a cached one to use + let Some(header) = assembler + .get_header(&column.block_root) + .map(GossipVerifiedPartialDataColumnHeader::new_from_cached) + else { + return PartialColumnVerificationResult::Err( + GossipPartialDataColumnError::MissingHeader, + ); + }; + + // If there was no header, there must be at least one cell. + if column.sidecar.column.is_empty() { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::EmptyMessage, + header, + }; + } + + header + }; + + // The number of cells nad proofs must match the population count of the bitmap. + let bitmap_popcount = column.sidecar.cells_present_bitmap.num_set_bits(); + let cells_len = column.sidecar.column.len(); + let proofs_len = column.sidecar.kzg_proofs.len(); + if bitmap_popcount != cells_len || bitmap_popcount != proofs_len { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentPresentCount { + bitmap_popcount, + cells_len, + proofs_len, + }, + header, + }; + } + + let bitmap_len = column.sidecar.cells_present_bitmap.len(); + let commitments_len = header.as_header().kzg_commitments.len(); + if bitmap_len != commitments_len { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentCommitmentsLength { + bitmap_len, + commitments_len, + }, + header, + }; + } + + let column = Arc::from(column); + let cells_to_kzg_verify = match chain + .data_availability_checker + .missing_cells_for_partial_column_sidecar(&column) + { + Ok(Some(cells_to_kzg_verify)) => cells_to_kzg_verify, + Ok(None) => { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipDataColumnError::PriorKnownUnpublished.into(), + header, + }; + } + Err(MissingCellsError::MismatchesCachedColumn) => { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipDataColumnError::MismatchesCachedColumn.into(), + header, + }; + } + Err(MissingCellsError::UnexpectedError(e)) => todo!("handle unexpected error {:?}", e), + }; + + // We do not have to check block related data here, as we create the verifiable column from + // gossip accepted block + let kzg = &chain.kzg; + let column = match verify_kzg_for_partial_data_column( + column.clone(), + cells_to_kzg_verify, + &header, + kzg, + seen_timestamp, + ) { + Ok(column) => column, + Err(err) => { + return PartialColumnVerificationResult::ErrWithValidHeader { err, header }; + } + }; + + PartialColumnVerificationResult::Ok { column, header } +} + +/// The result of a `validate_partial_data_column_sidecar_for_gossip` call. Any headers returned +/// herein were cached during this call or previously cached. +pub enum PartialColumnVerificationResult<E: EthSpec> { + /// Verification succeeded fully. + Ok { + column: KzgVerifiedPartialDataColumn<E>, + header: GossipVerifiedPartialDataColumnHeader<E>, + }, + /// Verification of the column failed, but the header is valid. + ErrWithValidHeader { + err: GossipPartialDataColumnError, + header: GossipVerifiedPartialDataColumnHeader<E>, + }, + /// Verification of the column or header failed, and no valid header was cached previously. + Err(GossipPartialDataColumnError), +} + /// Verify if the data column sidecar is valid. fn verify_data_column_sidecar<E: EthSpec>( data_column: &DataColumnSidecar<E>, spec: &ChainSpec, ) -> Result<(), GossipDataColumnError> { - if data_column.index >= E::number_of_columns() as u64 { - return Err(GossipDataColumnError::InvalidColumnIndex(data_column.index)); + if *data_column.index() >= E::number_of_columns() as u64 { + return Err(GossipDataColumnError::InvalidColumnIndex( + *data_column.index(), + )); } - if data_column.kzg_commitments.is_empty() { + + // TODO(gloas): implement Gloas verification that takes kzg_commitments from block as parameter + let commitments_len = match data_column { + DataColumnSidecar::Fulu(dc) => dc.kzg_commitments.len(), + DataColumnSidecar::Gloas(_) => return Err(GossipDataColumnError::InvalidVariant), + }; + + if commitments_len == 0 { return Err(GossipDataColumnError::UnexpectedDataColumn); } - let cells_len = data_column.column.len(); - let commitments_len = data_column.kzg_commitments.len(); - let proofs_len = data_column.kzg_proofs.len(); + let cells_len = data_column.column().len(); + let proofs_len = data_column.kzg_proofs().len(); let max_blobs_per_block = spec.max_blobs_per_block(data_column.epoch()) as usize; if commitments_len > max_blobs_per_block { @@ -582,23 +1170,24 @@ fn verify_is_unknown_sidecar<T: BeaconChainTypes>( chain: &BeaconChain<T>, column_sidecar: &DataColumnSidecar<T::EthSpec>, ) -> Result<(), GossipDataColumnError> { - if chain + if let Some(observation_key) = chain .observed_column_sidecars .read() - .proposer_is_known(column_sidecar) - .map_err(|e| GossipDataColumnError::BeaconChainError(Box::new(e.into())))? + .observation_key_is_known(column_sidecar) + .map_err(|e: ObservedDataSidecarsError| { + GossipDataColumnError::BeaconChainError(Box::new(e.into())) + })? { return Err(GossipDataColumnError::PriorKnown { - proposer: column_sidecar.block_proposer_index(), - slot: column_sidecar.slot(), - index: column_sidecar.index, + observation_key, + index: *column_sidecar.index(), }); } Ok(()) } fn verify_column_inclusion_proof<E: EthSpec>( - data_column: &DataColumnSidecar<E>, + data_column: &DataColumnSidecarFulu<E>, ) -> Result<(), GossipDataColumnError> { let _timer = metrics::start_timer(&metrics::DATA_COLUMN_SIDECAR_INCLUSION_PROOF_VERIFICATION); if !data_column.verify_inclusion_proof() { @@ -608,6 +1197,17 @@ fn verify_column_inclusion_proof<E: EthSpec>( Ok(()) } +fn verify_partial_column_header_inclusion_proof<E: EthSpec>( + header: &PartialDataColumnHeader<E>, +) -> Result<(), GossipDataColumnError> { + let _timer = metrics::start_timer(&metrics::DATA_COLUMN_SIDECAR_INCLUSION_PROOF_VERIFICATION); + if !header.verify_inclusion_proof() { + return Err(GossipDataColumnError::InvalidInclusionProof); + } + + Ok(()) +} + fn verify_slot_higher_than_parent( parent_block: &Block, data_column_slot: Slot, @@ -622,17 +1222,18 @@ fn verify_slot_higher_than_parent( } fn verify_parent_block_and_finalized_descendant<T: BeaconChainTypes>( - data_column: Arc<DataColumnSidecar<T::EthSpec>>, + block_parent_root: Hash256, + slot: Slot, chain: &BeaconChain<T>, ) -> Result<ProtoBlock, GossipDataColumnError> { let fork_choice = chain.canonical_head.fork_choice_read_lock(); // We have already verified that the column is past finalization, so we can // just check fork choice for the block's parent. - let block_parent_root = data_column.block_parent_root(); let Some(parent_block) = fork_choice.get_block(&block_parent_root) else { return Err(GossipDataColumnError::ParentUnknown { parent_root: block_parent_root, + slot, }); }; @@ -646,16 +1247,15 @@ fn verify_parent_block_and_finalized_descendant<T: BeaconChainTypes>( } fn verify_proposer_and_signature<T: BeaconChainTypes>( - data_column: &DataColumnSidecar<T::EthSpec>, + signed_block_header: &SignedBeaconBlockHeader, parent_block: &ProtoBlock, chain: &BeaconChain<T>, ) -> Result<(), GossipDataColumnError> { - let column_slot = data_column.slot(); + let column_slot = signed_block_header.message.slot; let slots_per_epoch = T::EthSpec::slots_per_epoch(); let column_epoch = column_slot.epoch(slots_per_epoch); - let column_index = data_column.index; - let block_root = data_column.block_root(); - let block_parent_root = data_column.block_parent_root(); + let block_root = signed_block_header.message.tree_hash_root(); + let block_parent_root = signed_block_header.message.parent_root; let proposer_shuffling_root = parent_block.proposer_shuffling_root_for_child_block(column_epoch, &chain.spec); @@ -667,9 +1267,10 @@ fn verify_proposer_and_signature<T: BeaconChainTypes>( || { debug!( %block_root, - index = %column_index, "Proposer shuffling cache miss for column verification" ); + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root chain .store .get_advanced_hot_state(block_parent_root, column_slot, parent_block.state_root) @@ -694,7 +1295,6 @@ fn verify_proposer_and_signature<T: BeaconChainTypes>( let pubkey = pubkey_cache .get(proposer_index) .ok_or_else(|| GossipDataColumnError::UnknownValidator(proposer_index as u64))?; - let signed_block_header = &data_column.signed_block_header; signed_block_header.verify_signature::<T::EthSpec>( pubkey, &fork, @@ -707,7 +1307,7 @@ fn verify_proposer_and_signature<T: BeaconChainTypes>( return Err(GossipDataColumnError::ProposalSignatureInvalid); } - let column_proposer_index = data_column.block_proposer_index(); + let column_proposer_index = signed_block_header.message.proposer_index; if proposer_index != column_proposer_index as usize { return Err(GossipDataColumnError::ProposerIndexMismatch { sidecar: column_proposer_index as usize, @@ -723,7 +1323,7 @@ fn verify_index_matches_subnet<E: EthSpec>( subnet: DataColumnSubnetId, spec: &ChainSpec, ) -> Result<(), GossipDataColumnError> { - let expected_subnet = DataColumnSubnetId::from_column_index(data_column.index, spec); + let expected_subnet = DataColumnSubnetId::from_column_index(*data_column.index(), spec); if expected_subnet != subnet { return Err(GossipDataColumnError::InvalidSubnetId { received: subnet.into(), @@ -772,27 +1372,31 @@ pub fn observe_gossip_data_column<T: BeaconChainTypes>( data_column_sidecar: &DataColumnSidecar<T::EthSpec>, chain: &BeaconChain<T>, ) -> Result<(), GossipDataColumnError> { - // Now the signature is valid, store the proposal so we don't accept another data column sidecar - // with the same `ColumnIndex`. It's important to double-check that the proposer still - // hasn't been observed so we don't have a race-condition when verifying two blocks + // Pre-gloas: Now the signature is valid, store the proposal so we don't accept another data column sidecar + // with the same `ColumnIndex`. + // Post-gloas: The block associated with the sidecar has already been imported into fork choice. Store the + // columns `beacon_block_root` so we don't accept another data column sidecar with the same `ColumnIndex`. + // It's important to double-check that the `Observationkey` still + // hasn't been observed so we don't have a race-condition when verifying two sidecars // simultaneously. // // Note: If this DataColumnSidecar goes on to fail full verification, we do not evict it from the // seen_cache as alternate data_column_sidecars for the same identifier can still be retrieved over // rpc. Evicting them from this cache would allow faster propagation over gossip. So we - // allow retrieval of potentially valid blocks over rpc, but try to punish the proposer for + // allow retrieval of potentially valid sidecars over rpc, but try to punish the proposer for // signing invalid messages. Issue for more background // https://github.com/ethereum/consensus-specs/issues/3261 - if chain + if let Some(observation_key) = chain .observed_column_sidecars .write() .observe_sidecar(data_column_sidecar) - .map_err(|e| GossipDataColumnError::BeaconChainError(Box::new(e.into())))? + .map_err(|e: ObservedDataSidecarsError| { + GossipDataColumnError::BeaconChainError(Box::new(e.into())) + })? { return Err(GossipDataColumnError::PriorKnown { - proposer: data_column_sidecar.block_proposer_index(), - slot: data_column_sidecar.slot(), - index: data_column_sidecar.index, + observation_key, + index: *data_column_sidecar.index(), }); } Ok(()) @@ -800,22 +1404,36 @@ pub fn observe_gossip_data_column<T: BeaconChainTypes>( #[cfg(test)] mod test { + use crate::ChainConfig; use crate::data_column_verification::{ - GossipDataColumnError, GossipVerifiedDataColumn, validate_data_column_sidecar_for_gossip, + GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, + GossipVerifiedPartialDataColumnHeader, KzgVerifiedCustodyPartialDataColumn, + PartialColumnVerificationResult, validate_data_column_sidecar_for_gossip_fulu, + validate_partial_data_column_sidecar_for_gossip, }; use crate::observed_data_sidecars::Observe; use crate::test_utils::{ - BeaconChainHarness, EphemeralHarnessType, generate_data_column_sidecars_from_block, + BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, + generate_data_column_sidecars_from_block, test_spec, }; use eth2::types::BlobsBundle; use execution_layer::test_utils::generate_blobs; + use kzg::KzgProof; + use ssz::BitList; + use ssz_types::VariableList; use std::sync::Arc; - use types::{DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkName, MainnetEthSpec}; + use std::time::UNIX_EPOCH; + use types::{ + Cell, CellBitmap, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, EthSpec, + ForkName, MainnetEthSpec, PartialDataColumn, PartialDataColumnHeader, + PartialDataColumnSidecar, + }; type E = MainnetEthSpec; + // TODO(gloas) make this generic over gloas/fulu #[tokio::test] - async fn test_validate_data_column_sidecar_for_gossip() { + async fn test_validate_data_column_sidecar_for_gossip_fulu() { // Setting up harness is slow, we initialise once and use it for all gossip validation tests. let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); let harness = BeaconChainHarness::builder(E::default()) @@ -827,19 +1445,20 @@ mod test { harness.advance_slot(); let verify_fn = |column_sidecar: DataColumnSidecar<E>| { - let col_index = column_sidecar.index; - validate_data_column_sidecar_for_gossip::<_, Observe>( + let col_index = *column_sidecar.index(); + validate_data_column_sidecar_for_gossip_fulu::<_, Observe>( column_sidecar.into(), DataColumnSubnetId::from_column_index(col_index, &harness.spec), &harness.chain, ) }; - empty_data_column_sidecars_fails_validation(&harness, &verify_fn).await; + empty_data_column_sidecars_fails_validation_fulu(&harness, &verify_fn).await; data_column_sidecar_commitments_exceed_max_blobs_per_block(&harness, &verify_fn).await; } + // TODO(gloas) make this generic over gloas/fulu #[tokio::test] - async fn test_new_for_block_publishing() { + async fn test_new_for_block_publishing_fulu() { // Setting up harness is slow, we initialise once and use it for all gossip validation tests. let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); let harness = BeaconChainHarness::builder(E::default()) @@ -856,11 +1475,12 @@ mod test { &harness.chain, ) }; - empty_data_column_sidecars_fails_validation(&harness, &verify_fn).await; + empty_data_column_sidecars_fails_validation_fulu(&harness, &verify_fn).await; data_column_sidecar_commitments_exceed_max_blobs_per_block(&harness, &verify_fn).await; } - async fn empty_data_column_sidecars_fails_validation<D>( + // TODO(gloas) make this generic over gloas/fulu + async fn empty_data_column_sidecars_fails_validation_fulu<D>( harness: &BeaconChainHarness<EphemeralHarnessType<E>>, verify_fn: &impl Fn(DataColumnSidecar<E>) -> Result<D, GossipDataColumnError>, ) { @@ -873,7 +1493,7 @@ mod test { .await; let index = 0; - let column_sidecar = DataColumnSidecar::<E> { + let column_sidecar: DataColumnSidecar<E> = DataColumnSidecar::Fulu(DataColumnSidecarFulu { index, column: vec![].try_into().unwrap(), kzg_commitments: vec![].try_into().unwrap(), @@ -884,7 +1504,7 @@ mod test { .body() .kzg_commitments_merkle_proof() .unwrap(), - }; + }); let result = verify_fn(column_sidecar); assert!(matches!( @@ -931,4 +1551,360 @@ mod test { Some(GossipDataColumnError::MaxBlobsPerBlockExceeded { .. }) )); } + + #[tokio::test] + async fn test_partial_message_verification_fulu() { + let spec = if fork_name_from_env().is_some() { + Arc::new(test_spec::<E>()) + } else { + Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())) + }; + + // Only run these tests if columns are enabled. + if !spec.is_fulu_scheduled() { + return; + } + // Gloas is not supported yet. + if spec.is_gloas_scheduled() { + return; + } + + let chain_config = ChainConfig { + enable_partial_columns: true, + ..Default::default() + }; + let harness = BeaconChainHarness::builder(E::default()) + .spec(spec) + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .chain_config(chain_config) + .build(); + + partial_empty_message_without_cells_returns_error(&harness).await; + partial_inconsistent_present_count_returns_error(&harness).await; + partial_inconsistent_max_count_returns_error(&harness).await; + partial_header_with_empty_commitments_fails(&harness).await; + partial_header_root_mismatch_fails(&harness).await; + partial_header_with_invalid_inclusion_proof_fails(&harness).await; + } + + /// Build a block containing 1 blob and pre-cache the header in the partial assembler. + async fn add_block_and_header( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) -> (types::Hash256, Arc<PartialDataColumnHeader<E>>) { + harness.advance_slot(); + // Generate a block with 1 blob so we have valid data columns. + let fork = harness + .spec + .fork_name_at_epoch(harness.get_current_slot().epoch(E::slots_per_epoch())); + let BlobsBundle::<E> { + commitments, + proofs: _, + blobs: _, + } = generate_blobs(1, fork).unwrap().0; + + let slot = harness.get_current_slot(); + let state = harness.get_current_state(); + let ((block, _blobs_opt), _state) = harness + .make_block_with_modifier(state, slot, |block| { + *block.body_mut().blob_kzg_commitments_mut().unwrap() = + vec![commitments[0]].try_into().unwrap(); + }) + .await; + + let block_root = block.canonical_root(); + let header: PartialDataColumnHeader<E> = block.as_ref().try_into().unwrap(); + let header = Arc::new(header); + + // Pre-cache the header in the partial assembler so headerless partials can be verified. + harness + .chain + .data_availability_checker + .partial_assembler() + .unwrap() + .init(block_root, header.clone()); + + (block_root, header) + } + + async fn partial_empty_message_without_cells_returns_error( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) { + let (block_root, header) = add_block_and_header(harness).await; + + // Create a headerless partial with no cells — should trigger EmptyMessage. + let num_commitments = header.kzg_commitments.len(); + let empty_bitmap = + BitList::<<E as EthSpec>::MaxBlobCommitmentsPerBlock>::with_capacity(num_commitments) + .unwrap(); + + let column = PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: empty_bitmap, + column: vec![].try_into().unwrap(), + kzg_proofs: vec![].try_into().unwrap(), + header: None.into(), + }, + }; + + let result = validate_partial_data_column_sidecar_for_gossip( + Box::new(column), + &harness.chain, + UNIX_EPOCH.elapsed().unwrap(), + ); + assert!( + matches!( + result, + PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::EmptyMessage, + .. + } + ), + "Expected EmptyMessage" + ); + } + + async fn partial_inconsistent_present_count_returns_error( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) { + let (block_root, header) = add_block_and_header(harness).await; + + // Create a bitmap that says 2 bits are set, but only provide 1 cell/proof. + let num_commitments = header.kzg_commitments.len(); + let mut bitmap = + BitList::<<E as EthSpec>::MaxBlobCommitmentsPerBlock>::with_capacity(num_commitments) + .unwrap(); + bitmap.set(0, true).unwrap(); + + let column = PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: vec![types::Cell::<E>::default()].try_into().unwrap(), + // Provide 2 proofs but only 1 cell ← mismatch with popcount=1 + kzg_proofs: vec![types::KzgProof::empty(), types::KzgProof::empty()] + .try_into() + .unwrap(), + header: None.into(), + }, + }; + + let result = validate_partial_data_column_sidecar_for_gossip( + Box::new(column), + &harness.chain, + UNIX_EPOCH.elapsed().unwrap(), + ); + assert!( + matches!( + result, + PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentPresentCount { .. }, + .. + } + ), + "Expected InconsistentPresentCount" + ); + } + + async fn partial_inconsistent_max_count_returns_error( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) { + let (block_root, _header) = add_block_and_header(harness).await; + + // Create a bitmap with length different from the number of commitments in the header. + // Header has 1 commitment, but we use a bitmap with capacity 3. + let mut bitmap = + BitList::<<E as EthSpec>::MaxBlobCommitmentsPerBlock>::with_capacity(3).unwrap(); + bitmap.set(0, true).unwrap(); + + let column = PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: vec![types::Cell::<E>::default()].try_into().unwrap(), + kzg_proofs: vec![types::KzgProof::empty()].try_into().unwrap(), + header: None.into(), + }, + }; + + let result = validate_partial_data_column_sidecar_for_gossip( + Box::new(column), + &harness.chain, + UNIX_EPOCH.elapsed().unwrap(), + ); + assert!( + matches!( + result, + PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentCommitmentsLength { .. }, + .. + } + ), + "Expected InconsistentMaxCount" + ); + } + + async fn partial_header_with_empty_commitments_fails( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) { + let slot = harness.get_current_slot(); + let state = harness.get_current_state(); + let ((block, _), _) = harness + .make_block_with_modifier(state, slot, |block| { + *block.body_mut().blob_kzg_commitments_mut().unwrap() = vec![].try_into().unwrap(); + }) + .await; + + let block_root = block.canonical_root(); + let header: PartialDataColumnHeader<E> = block.as_ref().try_into().unwrap(); + assert!(header.kzg_commitments.is_empty()); + + let result = + GossipVerifiedPartialDataColumnHeader::new(block_root, header, &*harness.chain); + assert!( + matches!( + result, + Err(GossipPartialDataColumnError::GossipDataColumnError( + GossipDataColumnError::UnexpectedDataColumn + )) + ), + "Expected UnexpectedDataColumn, got: {result:?}" + ); + } + + async fn partial_header_root_mismatch_fails( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) { + let (_block_root, header) = add_block_and_header(harness).await; + + // Use a wrong group_id (not matching the header's block root) + let wrong_root = types::Hash256::repeat_byte(0xff); + let header = PartialDataColumnHeader::clone(&header); + + let result = + GossipVerifiedPartialDataColumnHeader::new(wrong_root, header, &*harness.chain); + assert!( + matches!( + result, + Err(GossipPartialDataColumnError::HeaderIncorrectRoot { .. }) + ), + "Expected HeaderIncorrectRoot, got: {result:?}" + ); + } + + async fn partial_header_with_invalid_inclusion_proof_fails( + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, + ) { + let (block_root, header) = add_block_and_header(harness).await; + + // Corrupt the inclusion proof + let mut header = PartialDataColumnHeader::clone(&header); + header.kzg_commitments_inclusion_proof[0] = types::Hash256::repeat_byte(0xaa); + + let result = + GossipVerifiedPartialDataColumnHeader::new(block_root, header, &*harness.chain); + assert!( + matches!( + result, + Err(GossipPartialDataColumnError::GossipDataColumnError( + GossipDataColumnError::InvalidInclusionProof + )) + ), + "Expected InvalidInclusionProof, got: {result:?}" + ); + } + + // -- merge tests -- + + fn make_cell(marker: u8) -> Cell<E> { + let mut cell = Cell::<E>::default(); + cell[0] = marker; + cell + } + + fn make_partial_with_marker( + total_blobs: usize, + present_indices: &[usize], + marker_base: u8, + ) -> KzgVerifiedCustodyPartialDataColumn<E> { + let mut bitmap = CellBitmap::<E>::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + let column: VariableList<_, _> = present_indices + .iter() + .map(|&idx| make_cell(marker_base.wrapping_add(idx as u8))) + .collect::<Vec<_>>() + .try_into() + .unwrap(); + let proofs: VariableList<_, _> = present_indices + .iter() + .map(|_| KzgProof::empty()) + .collect::<Vec<_>>() + .try_into() + .unwrap(); + + KzgVerifiedCustodyPartialDataColumn { + data: Arc::new(PartialDataColumn { + block_root: Default::default(), + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column, + kzg_proofs: proofs, + header: None.into(), + }, + }), + latest_cell_timestamp: Default::default(), + } + } + + fn make_partial( + total_blobs: usize, + present_indices: &[usize], + ) -> KzgVerifiedCustodyPartialDataColumn<E> { + make_partial_with_marker(total_blobs, present_indices, 0) + } + + #[test] + fn merge_disjoint_partials() { + let a = make_partial(6, &[0, 2]); + let b = make_partial(6, &[1, 3]); + let merged = a.merge(&b).unwrap(); + assert_eq!(merged.data.sidecar.column.len(), 4); + assert_eq!(merged.data.sidecar.kzg_proofs.len(), 4); + for i in 0..4 { + assert!(merged.data.sidecar.cells_present_bitmap.get(i).unwrap()); + } + assert!(!merged.data.sidecar.cells_present_bitmap.get(4).unwrap()); + } + + #[test] + fn merge_overlapping_partials_prefers_self() { + let a = make_partial_with_marker(4, &[0, 1], 0); + let b = make_partial_with_marker(4, &[1, 2], 100); + let merged = a.merge(&b).unwrap(); + assert_eq!(merged.data.sidecar.column.len(), 3); + // Cell at bitmap index 1 is the second cell in the merged column. + // It should come from `a` (marker_base=0, so marker=0+1=1), not `b` (marker=100+1=101). + assert_eq!(merged.data.sidecar.column[1][0], 1); + } + + #[test] + fn merge_with_empty_other() { + let a = make_partial(4, &[0, 2]); + let b = make_partial(4, &[]); + let merged = a.merge(&b).unwrap(); + assert_eq!(merged.data.sidecar.column.len(), 2); + assert_eq!( + merged.data.sidecar.cells_present_bitmap, + a.data.sidecar.cells_present_bitmap + ); + } } diff --git a/beacon_node/beacon_chain/src/early_attester_cache.rs b/beacon_node/beacon_chain/src/early_attester_cache.rs index 5665ef3775..e3a83f9374 100644 --- a/beacon_node/beacon_chain/src/early_attester_cache.rs +++ b/beacon_node/beacon_chain/src/early_attester_cache.rs @@ -1,13 +1,81 @@ use crate::data_availability_checker::{AvailableBlock, AvailableBlockData}; -use crate::{ - attester_cache::{CommitteeLengths, Error}, - metrics, -}; +use crate::{BeaconChainError as Error, metrics}; use parking_lot::RwLock; use proto_array::Block as ProtoBlock; +use safe_arith::SafeArith; use std::sync::Arc; +use tracing::instrument; use types::*; +/// Stores the minimal amount of data required to compute the committee length for any committee at any +/// slot in a given `epoch`. +pub struct CommitteeLengths { + /// The `epoch` to which the lengths pertain. + epoch: Epoch, + /// The length of the shuffling in `self.epoch`. + active_validator_indices_len: usize, +} + +impl CommitteeLengths { + /// Instantiate `Self` using `state.current_epoch()`. + pub fn new<E: EthSpec>(state: &BeaconState<E>) -> Result<Self, Error> { + let active_validator_indices_len = state + .committee_cache(RelativeEpoch::Current)? + .active_validator_indices() + .len(); + + Ok(Self { + epoch: state.current_epoch(), + active_validator_indices_len, + }) + } + + /// Get the count of committees per each slot of `self.epoch`. + pub fn get_committee_count_per_slot<E: EthSpec>( + &self, + spec: &ChainSpec, + ) -> Result<usize, Error> { + E::get_committee_count_per_slot(self.active_validator_indices_len, spec).map_err(Into::into) + } + + /// Get the length of the committee at the given `slot` and `committee_index`. + pub fn get_committee_length<E: EthSpec>( + &self, + slot: Slot, + committee_index: CommitteeIndex, + spec: &ChainSpec, + ) -> Result<usize, Error> { + let slots_per_epoch = E::slots_per_epoch(); + let request_epoch = slot.epoch(slots_per_epoch); + + // Sanity check. + if request_epoch != self.epoch { + return Err(Error::EarlyAttesterCacheError); + } + + let slots_per_epoch = slots_per_epoch as usize; + let committees_per_slot = self.get_committee_count_per_slot::<E>(spec)?; + let index_in_epoch = compute_committee_index_in_epoch( + slot, + slots_per_epoch, + committees_per_slot, + committee_index as usize, + )?; + let epoch_committee_count = committees_per_slot.safe_mul(slots_per_epoch)?; + let range = compute_committee_range_in_epoch( + epoch_committee_count, + index_in_epoch, + self.active_validator_indices_len, + )? + .ok_or(Error::EarlyAttesterCacheError)?; + + range + .end + .checked_sub(range.start) + .ok_or(Error::EarlyAttesterCacheError) + } +} + pub struct CacheItem<E: EthSpec> { /* * Values used to create attestations. @@ -55,10 +123,9 @@ impl<E: EthSpec> EarlyAttesterCache<E> { block: &AvailableBlock<E>, proto_block: ProtoBlock, state: &BeaconState<E>, - spec: &ChainSpec, ) -> Result<(), Error> { let epoch = state.current_epoch(); - let committee_lengths = CommitteeLengths::new(state, spec)?; + let committee_lengths = CommitteeLengths::new(state)?; let source = state.current_justified_checkpoint(); let target_slot = epoch.start_slot(E::slots_per_epoch()); let target = Checkpoint { @@ -98,6 +165,13 @@ impl<E: EthSpec> EarlyAttesterCache<E> { /// - There is a cache `item` present. /// - If `request_slot` is in the same epoch as `item.epoch`. /// - If `request_index` does not exceed `item.committee_count`. + /// + /// Post gloas an additional condition must be met: + /// - `request_slot` is the same slot as `item.block.slot` (i.e. a same slot attestation). + /// + /// Non-same-slot Gloas attestations need `data.index` set from the canonical payload + /// status, which the cache doesn't track. Returning `None` falls through to fork choice. + #[instrument(skip_all, fields(%request_slot, %request_index), level = "debug")] pub fn try_attest( &self, request_slot: Slot, @@ -129,6 +203,12 @@ impl<E: EthSpec> EarlyAttesterCache<E> { item.committee_lengths .get_committee_length::<E>(request_slot, request_index, spec)?; + let is_same_slot_attestation = request_slot == item.block.slot(); + if spec.fork_name_at_slot::<E>(request_slot).gloas_enabled() && !is_same_slot_attestation { + return Ok(None); + } + let payload_present = false; + let attestation = Attestation::empty_for_signing( request_index, committee_len, @@ -136,6 +216,7 @@ impl<E: EthSpec> EarlyAttesterCache<E> { item.beacon_block_root, item.source, item.target, + payload_present, spec, ) .map_err(Error::AttestationError)?; @@ -188,4 +269,12 @@ impl<E: EthSpec> EarlyAttesterCache<E> { .filter(|item| item.beacon_block_root == block_root) .map(|item| item.proto_block.clone()) } + + /// Fetch the slot and block root of the current head block. + pub fn get_head_block_root(&self) -> Option<(Slot, Hash256)> { + self.item + .read() + .as_ref() + .map(|item| (item.block.slot(), item.beacon_block_root)) + } } diff --git a/beacon_node/beacon_chain/src/envelope_times_cache.rs b/beacon_node/beacon_chain/src/envelope_times_cache.rs new file mode 100644 index 0000000000..84c936c210 --- /dev/null +++ b/beacon_node/beacon_chain/src/envelope_times_cache.rs @@ -0,0 +1,197 @@ +//! This module provides the `EnvelopeTimesCache` which contains information regarding payload +//! envelope timings. +//! +//! This provides `BeaconChain` and associated functions with access to the timestamps of when a +//! payload envelope was observed, verified, executed, and imported. +//! This allows for better traceability and allows us to determine the root cause for why an +//! envelope was imported late. +//! This allows us to distinguish between the following scenarios: +//! - The envelope was observed late. +//! - Consensus verification was slow. +//! - Execution verification was slow. +//! - The DB write was slow. + +use eth2::types::{Hash256, Slot}; +use std::collections::HashMap; +use std::time::Duration; + +type BlockRoot = Hash256; + +#[derive(Clone, Default)] +pub struct EnvelopeTimestamps { + /// When the envelope was first observed (gossip or RPC). + pub observed: Option<Duration>, + /// When consensus verification (state transition) completed. + pub consensus_verified: Option<Duration>, + /// When execution layer verification started. + pub started_execution: Option<Duration>, + /// When execution layer verification completed. + pub executed: Option<Duration>, + /// When the envelope was imported into the DB. + pub imported: Option<Duration>, +} + +/// Delay data for envelope processing, computed relative to the slot start time. +#[derive(Debug, Default)] +pub struct EnvelopeDelays { + /// Time after start of slot we saw the envelope. + pub observed: Option<Duration>, + /// The time it took to complete consensus verification of the envelope. + pub consensus_verification_time: Option<Duration>, + /// The time it took to complete execution verification of the envelope. + pub execution_time: Option<Duration>, + /// Time after execution until the envelope was imported. + pub imported: Option<Duration>, +} + +impl EnvelopeDelays { + fn new(times: EnvelopeTimestamps, slot_start_time: Duration) -> EnvelopeDelays { + let observed = times + .observed + .and_then(|observed_time| observed_time.checked_sub(slot_start_time)); + let consensus_verification_time = times + .consensus_verified + .and_then(|consensus_verified| consensus_verified.checked_sub(times.observed?)); + let execution_time = times + .executed + .and_then(|executed| executed.checked_sub(times.started_execution?)); + let imported = times + .imported + .and_then(|imported_time| imported_time.checked_sub(times.executed?)); + EnvelopeDelays { + observed, + consensus_verification_time, + execution_time, + imported, + } + } +} + +pub struct EnvelopeTimesCacheValue { + pub slot: Slot, + pub timestamps: EnvelopeTimestamps, + pub peer_id: Option<String>, +} + +impl EnvelopeTimesCacheValue { + fn new(slot: Slot) -> Self { + EnvelopeTimesCacheValue { + slot, + timestamps: Default::default(), + peer_id: None, + } + } +} + +#[derive(Default)] +pub struct EnvelopeTimesCache { + pub cache: HashMap<BlockRoot, EnvelopeTimesCacheValue>, +} + +impl EnvelopeTimesCache { + /// Set the observation time for `block_root` to `timestamp` if `timestamp` is less than + /// any previous timestamp at which this envelope was observed. + pub fn set_time_observed( + &mut self, + block_root: BlockRoot, + slot: Slot, + timestamp: Duration, + peer_id: Option<String>, + ) { + let entry = self + .cache + .entry(block_root) + .or_insert_with(|| EnvelopeTimesCacheValue::new(slot)); + match entry.timestamps.observed { + Some(existing) if existing <= timestamp => { + // Existing timestamp is earlier, do nothing. + } + _ => { + entry.timestamps.observed = Some(timestamp); + entry.peer_id = peer_id; + } + } + } + + /// Set the timestamp for `field` if that timestamp is less than any previously known value. + fn set_time_if_less( + &mut self, + block_root: BlockRoot, + slot: Slot, + field: impl Fn(&mut EnvelopeTimestamps) -> &mut Option<Duration>, + timestamp: Duration, + ) { + let entry = self + .cache + .entry(block_root) + .or_insert_with(|| EnvelopeTimesCacheValue::new(slot)); + let existing_timestamp = field(&mut entry.timestamps); + if existing_timestamp.is_none_or(|prev| timestamp < prev) { + *existing_timestamp = Some(timestamp); + } + } + + pub fn set_time_consensus_verified( + &mut self, + block_root: BlockRoot, + slot: Slot, + timestamp: Duration, + ) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.consensus_verified, + timestamp, + ) + } + + pub fn set_time_started_execution( + &mut self, + block_root: BlockRoot, + slot: Slot, + timestamp: Duration, + ) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.started_execution, + timestamp, + ) + } + + pub fn set_time_executed(&mut self, block_root: BlockRoot, slot: Slot, timestamp: Duration) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.executed, + timestamp, + ) + } + + pub fn set_time_imported(&mut self, block_root: BlockRoot, slot: Slot, timestamp: Duration) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.imported, + timestamp, + ) + } + + pub fn get_envelope_delays( + &self, + block_root: BlockRoot, + slot_start_time: Duration, + ) -> EnvelopeDelays { + if let Some(entry) = self.cache.get(&block_root) { + EnvelopeDelays::new(entry.timestamps.clone(), slot_start_time) + } else { + EnvelopeDelays::default() + } + } + + /// Prune the cache to only store the most recent 2 epochs. + pub fn prune(&mut self, current_slot: Slot) { + self.cache + .retain(|_, entry| entry.slot > current_slot.saturating_sub(64_u64)); + } +} diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index 385a89c544..b8b88da476 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -1,4 +1,3 @@ -use crate::attester_cache::Error as AttesterCacheError; use crate::beacon_block_streamer::Error as BlockStreamerError; use crate::beacon_chain::ForkChoiceError; use crate::beacon_fork_choice_store::Error as ForkChoiceStoreError; @@ -9,6 +8,7 @@ use crate::observed_aggregates::Error as ObservedAttestationsError; use crate::observed_attesters::Error as ObservedAttestersError; use crate::observed_block_producers::Error as ObservedBlockProducersError; use crate::observed_data_sidecars::Error as ObservedDataSidecarsError; +use crate::payload_envelope_streamer::Error as EnvelopeStreamerError; use bls::PublicKeyBytes; use execution_layer::PayloadStatus; use fork_choice::ExecutionStatus; @@ -17,6 +17,7 @@ use milhouse::Error as MilhouseError; use operation_pool::OpPoolError; use safe_arith::ArithError; use ssz_types::Error as SszTypesError; +use state_processing::envelope_processing::EnvelopeProcessingError; use state_processing::{ BlockProcessingError, BlockReplayError, EpochProcessingError, SlotProcessingError, block_signature_verifier::Error as BlockSignatureVerifierError, @@ -53,6 +54,7 @@ pub enum BeaconChainError { }, SlotClockDidNotStart, NoStateForSlot(Slot), + NoBlockForSlot(Slot), BeaconStateError(BeaconStateError), EpochCacheError(EpochCacheError), DBInconsistent(String), @@ -61,6 +63,7 @@ pub enum BeaconChainError { ForkChoiceStoreError(ForkChoiceStoreError), MissingBeaconBlock(Hash256), MissingBeaconState(Hash256), + MissingExecutionPayloadEnvelope(Hash256), MissingHotStateSummary(Hash256), SlotProcessingError(SlotProcessingError), EpochProcessingError(EpochProcessingError), @@ -99,7 +102,7 @@ pub enum BeaconChainError { ObservedAttestersError(ObservedAttestersError), ObservedBlockProducersError(ObservedBlockProducersError), ObservedDataSidecarsError(ObservedDataSidecarsError), - AttesterCacheError(AttesterCacheError), + EarlyAttesterCacheError, PruningError(PruningError), ArithError(ArithError), InvalidShufflingId { @@ -159,6 +162,7 @@ pub enum BeaconChainError { reconstructed_transactions_root: Hash256, }, BlockStreamerError(BlockStreamerError), + EnvelopeStreamerError(EnvelopeStreamerError), AddPayloadLogicError, ExecutionForkChoiceUpdateFailed(execution_layer::Error), PrepareProposerFailed(BlockProcessingError), @@ -222,7 +226,7 @@ pub enum BeaconChainError { UnableToPublish, UnableToBuildColumnSidecar(String), AvailabilityCheckError(AvailabilityCheckError), - LightClientUpdateError(LightClientUpdateError), + LightClientError(LightClientError), LightClientBootstrapError(String), UnsupportedFork, MilhouseError(MilhouseError), @@ -268,7 +272,6 @@ easy_from_to!(ObservedAttestationsError, BeaconChainError); easy_from_to!(ObservedAttestersError, BeaconChainError); easy_from_to!(ObservedBlockProducersError, BeaconChainError); easy_from_to!(ObservedDataSidecarsError, BeaconChainError); -easy_from_to!(AttesterCacheError, BeaconChainError); easy_from_to!(BlockSignatureVerifierError, BeaconChainError); easy_from_to!(PruningError, BeaconChainError); easy_from_to!(ArithError, BeaconChainError); @@ -278,7 +281,7 @@ easy_from_to!(BlockReplayError, BeaconChainError); easy_from_to!(InconsistentFork, BeaconChainError); easy_from_to!(AvailabilityCheckError, BeaconChainError); easy_from_to!(EpochCacheError, BeaconChainError); -easy_from_to!(LightClientUpdateError, BeaconChainError); +easy_from_to!(LightClientError, BeaconChainError); easy_from_to!(MilhouseError, BeaconChainError); easy_from_to!(AttestationError, BeaconChainError); @@ -294,9 +297,6 @@ pub enum BlockProductionError { BeaconStateError(BeaconStateError), StateAdvanceError(StateAdvanceError), OpPoolError(OpPoolError), - /// The `BeaconChain` was explicitly configured _without_ a connection to eth1, therefore it - /// cannot produce blocks. - NoEth1ChainConnection, StateSlotTooHigh { produce_at_slot: Slot, state_slot: Slot, @@ -322,8 +322,12 @@ pub enum BlockProductionError { FailedToBuildBlobSidecars(String), MissingExecutionRequests, SszTypesError(ssz_types::Error), + EnvelopeProcessingError(EnvelopeProcessingError), + BlsError(bls::Error), + MissingParentExecutionPayload, + MissingExecutionPayloadEnvelope(Hash256), // TODO(gloas): Remove this once Gloas is implemented - GloasNotImplemented, + GloasNotImplemented(String), } easy_from_to!(BlockProcessingError, BlockProductionError); diff --git a/beacon_node/beacon_chain/src/events.rs b/beacon_node/beacon_chain/src/events.rs index 9c4a920654..198e1d4b1b 100644 --- a/beacon_node/beacon_chain/src/events.rs +++ b/beacon_node/beacon_chain/src/events.rs @@ -21,12 +21,16 @@ pub struct ServerSentEventHandler<E: EthSpec> { late_head: Sender<EventKind<E>>, light_client_finality_update_tx: Sender<EventKind<E>>, light_client_optimistic_update_tx: Sender<EventKind<E>>, - block_reward_tx: Sender<EventKind<E>>, proposer_slashing_tx: Sender<EventKind<E>>, attester_slashing_tx: Sender<EventKind<E>>, bls_to_execution_change_tx: Sender<EventKind<E>>, block_gossip_tx: Sender<EventKind<E>>, inclusion_list_tx: Sender<EventKind<E>>, + execution_payload_tx: Sender<EventKind<E>>, + execution_payload_gossip_tx: Sender<EventKind<E>>, + execution_payload_available_tx: Sender<EventKind<E>>, + execution_payload_bid_tx: Sender<EventKind<E>>, + payload_attestation_message_tx: Sender<EventKind<E>>, } impl<E: EthSpec> ServerSentEventHandler<E> { @@ -49,12 +53,16 @@ impl<E: EthSpec> ServerSentEventHandler<E> { let (late_head, _) = broadcast::channel(capacity); let (light_client_finality_update_tx, _) = broadcast::channel(capacity); let (light_client_optimistic_update_tx, _) = broadcast::channel(capacity); - let (block_reward_tx, _) = broadcast::channel(capacity); let (proposer_slashing_tx, _) = broadcast::channel(capacity); let (attester_slashing_tx, _) = broadcast::channel(capacity); let (bls_to_execution_change_tx, _) = broadcast::channel(capacity); let (block_gossip_tx, _) = broadcast::channel(capacity); let (inclusion_list_tx, _) = broadcast::channel(capacity); + let (execution_payload_tx, _) = broadcast::channel(capacity); + let (execution_payload_gossip_tx, _) = broadcast::channel(capacity); + let (execution_payload_available_tx, _) = broadcast::channel(capacity); + let (execution_payload_bid_tx, _) = broadcast::channel(capacity); + let (payload_attestation_message_tx, _) = broadcast::channel(capacity); Self { attestation_tx, @@ -71,12 +79,16 @@ impl<E: EthSpec> ServerSentEventHandler<E> { late_head, light_client_finality_update_tx, light_client_optimistic_update_tx, - block_reward_tx, proposer_slashing_tx, attester_slashing_tx, bls_to_execution_change_tx, block_gossip_tx, inclusion_list_tx, + execution_payload_tx, + execution_payload_gossip_tx, + execution_payload_available_tx, + execution_payload_bid_tx, + payload_attestation_message_tx, } } @@ -145,10 +157,6 @@ impl<E: EthSpec> ServerSentEventHandler<E> { .light_client_optimistic_update_tx .send(kind) .map(|count| log_count("light client optimistic update", count)), - EventKind::BlockReward(_) => self - .block_reward_tx - .send(kind) - .map(|count| log_count("block reward", count)), EventKind::ProposerSlashing(_) => self .proposer_slashing_tx .send(kind) @@ -169,6 +177,26 @@ impl<E: EthSpec> ServerSentEventHandler<E> { .inclusion_list_tx .send(kind) .map(|count| log_count("inclusion list", count)), + EventKind::ExecutionPayload(_) => self + .execution_payload_tx + .send(kind) + .map(|count| log_count("execution payload", count)), + EventKind::ExecutionPayloadGossip(_) => self + .execution_payload_gossip_tx + .send(kind) + .map(|count| log_count("execution payload gossip", count)), + EventKind::ExecutionPayloadAvailable(_) => self + .execution_payload_available_tx + .send(kind) + .map(|count| log_count("execution payload available", count)), + EventKind::ExecutionPayloadBid(_) => self + .execution_payload_bid_tx + .send(kind) + .map(|count| log_count("execution payload bid", count)), + EventKind::PayloadAttestationMessage(_) => self + .payload_attestation_message_tx + .send(kind) + .map(|count| log_count("payload attestation message", count)), }; if let Err(SendError(event)) = result { trace!(?event, "No receivers registered to listen for event"); @@ -231,10 +259,6 @@ impl<E: EthSpec> ServerSentEventHandler<E> { self.light_client_optimistic_update_tx.subscribe() } - pub fn subscribe_block_reward(&self) -> Receiver<EventKind<E>> { - self.block_reward_tx.subscribe() - } - pub fn subscribe_attester_slashing(&self) -> Receiver<EventKind<E>> { self.attester_slashing_tx.subscribe() } @@ -255,6 +279,26 @@ impl<E: EthSpec> ServerSentEventHandler<E> { self.inclusion_list_tx.subscribe() } + pub fn subscribe_execution_payload(&self) -> Receiver<EventKind<E>> { + self.execution_payload_tx.subscribe() + } + + pub fn subscribe_execution_payload_gossip(&self) -> Receiver<EventKind<E>> { + self.execution_payload_gossip_tx.subscribe() + } + + pub fn subscribe_execution_payload_available(&self) -> Receiver<EventKind<E>> { + self.execution_payload_available_tx.subscribe() + } + + pub fn subscribe_execution_payload_bid(&self) -> Receiver<EventKind<E>> { + self.execution_payload_bid_tx.subscribe() + } + + pub fn subscribe_payload_attestation_message(&self) -> Receiver<EventKind<E>> { + self.payload_attestation_message_tx.subscribe() + } + pub fn has_attestation_subscribers(&self) -> bool { self.attestation_tx.receiver_count() > 0 } @@ -303,10 +347,6 @@ impl<E: EthSpec> ServerSentEventHandler<E> { self.late_head.receiver_count() > 0 } - pub fn has_block_reward_subscribers(&self) -> bool { - self.block_reward_tx.receiver_count() > 0 - } - pub fn has_proposer_slashing_subscribers(&self) -> bool { self.proposer_slashing_tx.receiver_count() > 0 } @@ -326,4 +366,24 @@ impl<E: EthSpec> ServerSentEventHandler<E> { pub fn has_inclusion_list_subscribers(&self) -> bool { self.inclusion_list_tx.receiver_count() > 0 } + + pub fn has_execution_payload_subscribers(&self) -> bool { + self.execution_payload_tx.receiver_count() > 0 + } + + pub fn has_execution_payload_gossip_subscribers(&self) -> bool { + self.execution_payload_gossip_tx.receiver_count() > 0 + } + + pub fn has_execution_payload_available_subscribers(&self) -> bool { + self.execution_payload_available_tx.receiver_count() > 0 + } + + pub fn has_execution_payload_bid_subscribers(&self) -> bool { + self.execution_payload_bid_tx.receiver_count() > 0 + } + + pub fn has_payload_attestation_message_subscribers(&self) -> bool { + self.payload_attestation_message_tx.receiver_count() > 0 + } } diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index 5cef219a19..04f25c35b3 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -12,8 +12,8 @@ use crate::{ ExecutionPayloadError, }; use execution_layer::{ - BlockProposalContents, BlockProposalContentsType, BuilderParams, NewPayloadRequest, - PayloadAttributes, PayloadParameters, PayloadStatus, + BlockProposalContentsType, BuilderParams, NewPayloadRequest, PayloadAttributes, + PayloadParameters, PayloadStatus, }; use fork_choice::{InvalidationOperation, PayloadVerificationStatus}; use proto_array::{Block as ProtoBlock, ExecutionStatus}; @@ -21,24 +21,18 @@ use slot_clock::SlotClock; use ssz_types::VariableList; use state_processing::per_block_processing::{ compute_timestamp_at_slot, get_expected_withdrawals, is_execution_enabled, - is_merge_transition_complete, partially_verify_execution_payload, + partially_verify_execution_payload, }; use std::sync::Arc; use tokio::task::JoinHandle; use tracing::{Instrument, debug, debug_span, info, warn}; use tree_hash::TreeHash; -use types::payload::BlockProductionVersion; +use types::execution::BlockProductionVersion; use types::*; pub type PreparePayloadResult<E> = Result<BlockProposalContentsType<E>, BlockProductionError>; pub type PreparePayloadHandle<E> = JoinHandle<Option<PreparePayloadResult<E>>>; -#[derive(PartialEq)] -pub enum AllowOptimisticImport { - Yes, - No, -} - /// Signal whether the execution payloads of new blocks should be /// immediately verified with the EL or imported optimistically without /// any EL communication. @@ -64,7 +58,10 @@ impl<T: BeaconChainTypes> PayloadNotifier<T> { state: &BeaconState<T::EthSpec>, notify_execution_layer: NotifyExecutionLayer, ) -> Result<Self, BlockError> { - let payload_verification_status = if is_execution_enabled(state, block.message().body()) { + let payload_verification_status = if block.fork_name_unchecked().gloas_enabled() { + // Gloas blocks don't contain an execution payload. + Some(PayloadVerificationStatus::Irrelevant) + } else if is_execution_enabled(state, block.message().body()) { // Perform the initial stages of payload verification. // // We will duplicate these checks again during `per_block_processing`, however these @@ -152,7 +149,7 @@ impl<T: BeaconChainTypes> PayloadNotifier<T> { } } -/// Verify that `execution_payload` contained by `block` is considered valid by an execution +/// Verify that `execution_payload` is considered valid by an execution /// engine. /// /// ## Specification @@ -161,18 +158,18 @@ impl<T: BeaconChainTypes> PayloadNotifier<T> { /// contains a few extra checks by running `partially_verify_execution_payload` first: /// /// https://github.com/ethereum/consensus-specs/blob/v1.1.9/specs/bellatrix/beacon-chain.md#notify_new_payload -async fn notify_new_payload<T: BeaconChainTypes>( +pub async fn notify_new_payload<T: BeaconChainTypes>( chain: &Arc<BeaconChain<T>>, block: BeaconBlockRef<'_, T::EthSpec>, il_transactions: Transactions<T::EthSpec>, ) -> Result<PayloadVerificationStatus, BlockError> { + let slot = block.slot(); + let parent_beacon_block_root = block.parent_root(); let execution_layer = chain .execution_layer .as_ref() .ok_or(ExecutionPayloadError::NoExecutionConnection)?; - let execution_block_hash = block.execution_payload()?.block_hash(); - // TODO(eip-7805) we can remove this later if !il_transactions.is_empty() { info!( @@ -181,11 +178,11 @@ async fn notify_new_payload<T: BeaconChainTypes>( ); } + let new_payload_request = + NewPayloadRequest::try_from_block_and_il_transactions(block, il_transactions)?; + let execution_block_hash = new_payload_request.execution_payload_ref().block_hash(); let new_payload_response = execution_layer - .notify_new_payload(NewPayloadRequest::try_from_block_and_il_transactions( - block, - il_transactions, - )?) + .notify_new_payload(new_payload_request.clone()) .await; match new_payload_response { @@ -202,10 +199,7 @@ async fn notify_new_payload<T: BeaconChainTypes>( ?validation_error, ?latest_valid_hash, ?execution_block_hash, - root = ?block.tree_hash_root(), - graffiti = block.body().graffiti().as_utf8_lossy(), - proposer_index = block.proposer_index(), - slot = %block.slot(), + %slot, method = "new_payload", "Invalid execution payload" ); @@ -228,9 +222,7 @@ async fn notify_new_payload<T: BeaconChainTypes>( { // This block has not yet been applied to fork choice, so the latest block that was // imported to fork choice was the parent. - let latest_root = block.parent_root(); - - // If the payload is invalid because it didn't satisfy the inclusion lit + // If the payload is invalid because it didn't satisfy the inclusion list // transactions for this slot, update the fork choice store before processing // the invalid EL payload. if *validation_error == Some("INVALID_INCLUSION_LIST".to_string()) { @@ -249,7 +241,7 @@ async fn notify_new_payload<T: BeaconChainTypes>( chain .process_invalid_execution_payload(&InvalidationOperation::InvalidateMany { - head_block_root: latest_root, + head_block_root: parent_beacon_block_root, always_invalidate_head: false, latest_valid_ancestor: latest_valid_hash, }) @@ -264,10 +256,7 @@ async fn notify_new_payload<T: BeaconChainTypes>( warn!( ?validation_error, ?execution_block_hash, - root = ?block.tree_hash_root(), - graffiti = block.body().graffiti().as_utf8_lossy(), - proposer_index = block.proposer_index(), - slot = %block.slot(), + %slot, method = "new_payload", "Invalid execution payload block hash" ); @@ -282,75 +271,71 @@ async fn notify_new_payload<T: BeaconChainTypes>( } } -/// Verify that the block which triggers the merge is valid to be imported to fork choice. -/// -/// ## Errors -/// -/// Will return an error when using a pre-merge fork `state`. Ensure to only run this function -/// after the merge fork. -/// -/// ## Specification -/// -/// Equivalent to the `validate_merge_block` function in the merge Fork Choice Changes: -/// -/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/fork-choice.md#validate_merge_block -pub async fn validate_merge_block<T: BeaconChainTypes>( +/// Notify the EL with a pre-built `NewPayloadRequest`. Used for Gloas where the payload +/// comes from a `SignedExecutionPayloadEnvelope` rather than the beacon block body. +pub async fn notify_new_payload_with_request<T: BeaconChainTypes>( chain: &Arc<BeaconChain<T>>, - block: BeaconBlockRef<'_, T::EthSpec>, - allow_optimistic_import: AllowOptimisticImport, -) -> Result<(), BlockError> { - let spec = &chain.spec; - let block_epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); - let execution_payload = block.execution_payload()?; - - if spec.terminal_block_hash != ExecutionBlockHash::zero() { - if block_epoch < spec.terminal_block_hash_activation_epoch { - return Err(ExecutionPayloadError::InvalidActivationEpoch { - activation_epoch: spec.terminal_block_hash_activation_epoch, - epoch: block_epoch, - } - .into()); - } - - if execution_payload.parent_hash() != spec.terminal_block_hash { - return Err(ExecutionPayloadError::InvalidTerminalBlockHash { - terminal_block_hash: spec.terminal_block_hash, - payload_parent_hash: execution_payload.parent_hash(), - } - .into()); - } - - return Ok(()); - } - + slot: Slot, + parent_beacon_block_root: Hash256, + new_payload_request: NewPayloadRequest<'_, T::EthSpec>, +) -> Result<PayloadVerificationStatus, BlockError> { let execution_layer = chain .execution_layer .as_ref() .ok_or(ExecutionPayloadError::NoExecutionConnection)?; - let is_valid_terminal_pow_block = execution_layer - .is_valid_terminal_pow_block_hash(execution_payload.parent_hash(), spec) - .await - .map_err(ExecutionPayloadError::from)?; + let execution_block_hash = new_payload_request.execution_payload_ref().block_hash(); + let new_payload_response = execution_layer + .notify_new_payload(new_payload_request.clone()) + .await; - match is_valid_terminal_pow_block { - Some(true) => Ok(()), - Some(false) => Err(ExecutionPayloadError::InvalidTerminalPoWBlock { - parent_hash: execution_payload.parent_hash(), - } - .into()), - None => { - if allow_optimistic_import == AllowOptimisticImport::Yes { - debug!( - block_hash = ?execution_payload.parent_hash(), - msg = "the terminal block/parent was unavailable", - "Optimistically importing merge transition block" - ); - Ok(()) - } else { - Err(ExecutionPayloadError::UnverifiedNonOptimisticCandidate.into()) + match new_payload_response { + Ok(status) => match status { + PayloadStatus::Valid => Ok(PayloadVerificationStatus::Verified), + PayloadStatus::Syncing | PayloadStatus::Accepted => { + Ok(PayloadVerificationStatus::Optimistic) } - } + PayloadStatus::Invalid { + latest_valid_hash, + ref validation_error, + } => { + warn!( + ?validation_error, + ?latest_valid_hash, + ?execution_block_hash, + %slot, + method = "new_payload", + "Invalid execution payload" + ); + + if let Some(latest_valid_hash) = + latest_valid_hash.filter(|hash| *hash != ExecutionBlockHash::zero()) + { + chain + .process_invalid_execution_payload(&InvalidationOperation::InvalidateMany { + head_block_root: parent_beacon_block_root, + always_invalidate_head: false, + latest_valid_ancestor: latest_valid_hash, + }) + .await?; + } + + Err(ExecutionPayloadError::RejectedByExecutionEngine { status }.into()) + } + PayloadStatus::InvalidBlockHash { + ref validation_error, + } => { + warn!( + ?validation_error, + ?execution_block_hash, + %slot, + method = "new_payload", + "Invalid execution payload block hash" + ); + Err(ExecutionPayloadError::RejectedByExecutionEngine { status }.into()) + } + }, + Err(e) => Err(ExecutionPayloadError::RequestFailed(e).into()), } } @@ -361,16 +346,22 @@ pub fn validate_execution_payload_for_gossip<T: BeaconChainTypes>( block: BeaconBlockRef<'_, T::EthSpec>, chain: &BeaconChain<T>, ) -> Result<(), BlockError> { + // Gloas blocks don't have an execution payload in the block body. + // Bid-related validations are handled in gossip block verification. + if block.fork_name_unchecked().gloas_enabled() { + return Ok(()); + } + // Only apply this validation if this is a Bellatrix beacon block. if let Ok(execution_payload) = block.body().execution_payload() { - // This logic should match `is_execution_enabled`. We use only the execution block hash of - // the parent here in order to avoid loading the parent state during gossip verification. + // Check parent execution status to determine if we should validate the payload. + // We use only the execution status of the parent here to avoid loading the parent state + // during gossip verification. - let is_merge_transition_complete = match parent_block.execution_status { - // Optimistically declare that an "unknown" status block has completed the merge. + let parent_has_execution = match parent_block.execution_status { + // Parent has valid or optimistic execution status. ExecutionStatus::Valid(_) | ExecutionStatus::Optimistic(_) => true, - // It's impossible for an irrelevant block to have completed the merge. It is pre-merge - // by definition. + // Pre-merge blocks have irrelevant execution status. ExecutionStatus::Irrelevant(_) => false, // If the parent has an invalid payload then it's impossible to build a valid block upon // it. Reject the block. @@ -381,7 +372,7 @@ pub fn validate_execution_payload_for_gossip<T: BeaconChainTypes>( } }; - if is_merge_transition_complete || !execution_payload.is_default_with_empty_roots() { + if parent_has_execution || !execution_payload.is_default_with_empty_roots() { let expected_timestamp = chain .slot_clock .start_of(block.slot()) @@ -430,7 +421,6 @@ pub fn get_execution_payload<T: BeaconChainTypes>( // task. let spec = &chain.spec; let current_epoch = state.current_epoch(); - let is_merge_transition_complete = is_merge_transition_complete(state); let timestamp = compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; @@ -438,7 +428,7 @@ pub fn get_execution_payload<T: BeaconChainTypes>( let latest_execution_payload_header_block_hash = latest_execution_payload_header.block_hash(); let latest_execution_payload_header_gas_limit = latest_execution_payload_header.gas_limit(); let withdrawals = if state.fork_name_unchecked().capella_enabled() { - Some(get_expected_withdrawals(state, spec)?.0.into()) + Some(Withdrawals::<T::EthSpec>::from(get_expected_withdrawals(state, spec)?).into()) } else { None }; @@ -457,7 +447,6 @@ pub fn get_execution_payload<T: BeaconChainTypes>( async move { prepare_execution_payload::<T>( &chain, - is_merge_transition_complete, timestamp, random, proposer_index, @@ -481,8 +470,6 @@ pub fn get_execution_payload<T: BeaconChainTypes>( /// Prepares an execution payload for inclusion in a block. /// -/// Will return `Ok(None)` if the Bellatrix fork has occurred, but a terminal block has not been found. -/// /// ## Errors /// /// Will return an error when using a pre-Bellatrix fork `state`. Ensure to only run this function @@ -496,7 +483,6 @@ pub fn get_execution_payload<T: BeaconChainTypes>( #[allow(clippy::too_many_arguments)] pub async fn prepare_execution_payload<T>( chain: &Arc<BeaconChain<T>>, - is_merge_transition_complete: bool, timestamp: u64, random: Hash256, proposer_index: u64, @@ -511,7 +497,6 @@ pub async fn prepare_execution_payload<T>( where T: BeaconChainTypes, { - let current_epoch = builder_params.slot.epoch(T::EthSpec::slots_per_epoch()); let spec = &chain.spec; let fork = spec.fork_name_at_slot::<T::EthSpec>(builder_params.slot); let execution_layer = chain @@ -519,42 +504,7 @@ where .as_ref() .ok_or(BlockProductionError::ExecutionLayerMissing)?; - let parent_hash = if !is_merge_transition_complete { - let is_terminal_block_hash_set = spec.terminal_block_hash != ExecutionBlockHash::zero(); - let is_activation_epoch_reached = - current_epoch >= spec.terminal_block_hash_activation_epoch; - - if is_terminal_block_hash_set && !is_activation_epoch_reached { - // Use the "empty" payload if there's a terminal block hash, but we haven't reached the - // terminal block epoch yet. - return Ok(BlockProposalContentsType::Full( - BlockProposalContents::Payload { - payload: FullPayload::default_at_fork(fork)?, - block_value: Uint256::ZERO, - }, - )); - } - - let terminal_pow_block_hash = execution_layer - .get_terminal_pow_block_hash(spec, timestamp) - .await - .map_err(BlockProductionError::TerminalPoWBlockLookupFailed)?; - - if let Some(terminal_pow_block_hash) = terminal_pow_block_hash { - terminal_pow_block_hash - } else { - // If the merge transition hasn't occurred yet and the EL hasn't found the terminal - // block, return an "empty" payload. - return Ok(BlockProposalContentsType::Full( - BlockProposalContents::Payload { - payload: FullPayload::default_at_fork(fork)?, - block_value: Uint256::ZERO, - }, - )); - } - } else { - latest_execution_payload_header_block_hash - }; + let parent_hash = latest_execution_payload_header_block_hash; // Try to obtain the fork choice update parameters from the cached head. // @@ -578,12 +528,20 @@ where let suggested_fee_recipient = execution_layer .get_suggested_fee_recipient(proposer_index) .await; + + let slot_number = if fork.gloas_enabled() { + Some(builder_params.slot.as_u64()) + } else { + None + }; + let payload_attributes = PayloadAttributes::new( timestamp, random, suggested_fee_recipient, withdrawals, parent_beacon_block_root, + slot_number, ); let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; diff --git a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs index 9526921da7..c94fb036f8 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs @@ -1,7 +1,8 @@ use crate::fetch_blobs::{EngineGetBlobsOutput, FetchEngineBlobError}; -use crate::observed_block_producers::ProposalKey; +use crate::observed_data_sidecars::ObservationKey; +use crate::partial_data_column_assembler::PartialDataColumnAssembler; use crate::{AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes}; -use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2}; +use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2, BlobAndProofV3}; use kzg::Kzg; #[cfg(test)] use mockall::automock; @@ -35,6 +36,13 @@ impl<T: BeaconChainTypes> FetchBlobsBeaconAdapter<T> { &self.chain.task_executor } + pub(crate) fn partial_assembler(&self) -> Option<Arc<PartialDataColumnAssembler<T::EthSpec>>> { + self.chain + .data_availability_checker + .partial_assembler() + .cloned() + } + pub(crate) async fn get_blobs_v1( &self, versioned_hashes: Vec<Hash256>, @@ -67,27 +75,41 @@ impl<T: BeaconChainTypes> FetchBlobsBeaconAdapter<T> { .map_err(FetchEngineBlobError::RequestFailed) } - pub(crate) fn blobs_known_for_proposal( + pub(crate) async fn get_blobs_v3( &self, - proposer: u64, - slot: Slot, + versioned_hashes: Vec<Hash256>, + ) -> Result<Option<Vec<BlobAndProofV3<T::EthSpec>>>, FetchEngineBlobError> { + let execution_layer = self + .chain + .execution_layer + .as_ref() + .ok_or(FetchEngineBlobError::ExecutionLayerMissing)?; + + execution_layer + .get_blobs_v3(versioned_hashes) + .await + .map_err(FetchEngineBlobError::RequestFailed) + } + + pub(crate) fn blobs_known_for_observation_key( + &self, + observation_key: ObservationKey, ) -> Option<HashSet<u64>> { - let proposer_key = ProposalKey::new(proposer, slot); self.chain .observed_blob_sidecars .read() - .known_for_proposal(&proposer_key) + .known_for_observation_key(&observation_key) .cloned() } - pub(crate) fn data_column_known_for_proposal( + pub(crate) fn data_column_known_for_observation_key( &self, - proposal_key: ProposalKey, + observation_key: ObservationKey, ) -> Option<HashSet<ColumnIndex>> { self.chain .observed_column_sidecars .read() - .known_for_proposal(&proposal_key) + .known_for_observation_key(&observation_key) .cloned() } @@ -121,4 +143,18 @@ impl<T: BeaconChainTypes> FetchBlobsBeaconAdapter<T> { .fork_choice_read_lock() .contains_block(block_root) } + + pub(crate) async fn supports_get_blobs_v3(&self) -> Result<bool, FetchEngineBlobError> { + let execution_layer = self + .chain + .execution_layer + .as_ref() + .ok_or(FetchEngineBlobError::ExecutionLayerMissing)?; + + execution_layer + .get_engine_capabilities(None) + .await + .map_err(FetchEngineBlobError::RequestFailed) + .map(|caps| caps.get_blobs_v3) + } } diff --git a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs index 4c6b2d10a9..f7b4b8a29e 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs @@ -13,32 +13,28 @@ mod fetch_blobs_beacon_adapter; mod tests; use crate::blob_verification::{GossipBlobError, KzgVerifiedBlob}; -use crate::block_verification_types::AsBlock; -use crate::data_column_verification::{KzgVerifiedCustodyDataColumn, KzgVerifiedDataColumn}; +use crate::data_column_verification::{ + KzgVerifiedCustodyDataColumn, KzgVerifiedCustodyPartialDataColumn, KzgVerifiedPartialDataColumn, +}; #[cfg_attr(test, double)] use crate::fetch_blobs::fetch_blobs_beacon_adapter::FetchBlobsBeaconAdapter; -use crate::kzg_utils::blobs_to_data_column_sidecars; -use crate::observed_block_producers::ProposalKey; -use crate::validator_monitor::timestamp_now; +use crate::kzg_utils::blobs_to_partial_data_columns; +use crate::observed_data_sidecars::ObservationKey; use crate::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, metrics, }; use execution_layer::Error as ExecutionLayerError; -use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2}; +use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2, BlobAndProofV3}; use metrics::{TryExt, inc_counter}; #[cfg(test)] use mockall_double::double; -use ssz_types::FixedVector; +use slot_clock::timestamp_now; use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; use std::sync::Arc; -use tracing::{Span, debug, instrument, warn}; -use types::blob_sidecar::BlobSidecarError; -use types::data_column_sidecar::DataColumnSidecarError; -use types::{ - BeaconStateError, Blob, BlobSidecar, ColumnIndex, EthSpec, FullPayload, Hash256, KzgProofs, - SignedBeaconBlock, SignedBeaconBlockHeader, VersionedHash, -}; +use tracing::{debug, instrument, warn}; +use types::data::{BlobSidecarError, ColumnIndex, DataColumnSidecarError, PartialDataColumnHeader}; +use types::{BeaconStateError, BlobSidecar, EthSpec, Hash256, VersionedHash}; /// Result from engine get blobs to be passed onto `DataAvailabilityChecker` and published to the /// gossip network. The blobs / data columns have not been marked as observed yet, as they may not @@ -72,14 +68,14 @@ pub enum FetchEngineBlobError { pub async fn fetch_and_process_engine_blobs<T: BeaconChainTypes>( chain: Arc<BeaconChain<T>>, block_root: Hash256, - block: Arc<SignedBeaconBlock<T::EthSpec, FullPayload<T::EthSpec>>>, + header: Arc<PartialDataColumnHeader<T::EthSpec>>, custody_columns: &[ColumnIndex], publish_fn: impl Fn(EngineGetBlobsOutput<T>) + Send + 'static, ) -> Result<Option<AvailabilityProcessingStatus>, FetchEngineBlobError> { fetch_and_process_engine_blobs_inner( FetchBlobsBeaconAdapter::new(chain), block_root, - block, + header, custody_columns, publish_fn, ) @@ -91,22 +87,16 @@ pub async fn fetch_and_process_engine_blobs<T: BeaconChainTypes>( async fn fetch_and_process_engine_blobs_inner<T: BeaconChainTypes>( chain_adapter: FetchBlobsBeaconAdapter<T>, block_root: Hash256, - block: Arc<SignedBeaconBlock<T::EthSpec, FullPayload<T::EthSpec>>>, + header: Arc<PartialDataColumnHeader<T::EthSpec>>, custody_columns: &[ColumnIndex], publish_fn: impl Fn(EngineGetBlobsOutput<T>) + Send + 'static, ) -> Result<Option<AvailabilityProcessingStatus>, FetchEngineBlobError> { - let versioned_hashes = if let Some(kzg_commitments) = block - .message() - .body() - .blob_kzg_commitments() - .ok() - .filter(|blobs| !blobs.is_empty()) - { - kzg_commitments - .iter() - .map(kzg_commitment_to_versioned_hash) - .collect::<Vec<_>>() - } else { + let versioned_hashes = header + .kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect::<Vec<_>>(); + if versioned_hashes.is_empty() { debug!("Fetch blobs not triggered - none required"); return Ok(None); }; @@ -118,12 +108,12 @@ async fn fetch_and_process_engine_blobs_inner<T: BeaconChainTypes>( if chain_adapter .spec() - .is_peer_das_enabled_for_epoch(block.epoch()) + .is_peer_das_enabled_for_epoch(header.slot().epoch(T::EthSpec::slots_per_epoch())) { - fetch_and_process_blobs_v2( + fetch_and_process_blobs_v2_or_v3( chain_adapter, block_root, - block, + header, versioned_hashes, custody_columns, publish_fn, @@ -133,7 +123,7 @@ async fn fetch_and_process_engine_blobs_inner<T: BeaconChainTypes>( fetch_and_process_blobs_v1( chain_adapter, block_root, - block, + &header, versioned_hashes, publish_fn, ) @@ -145,7 +135,7 @@ async fn fetch_and_process_engine_blobs_inner<T: BeaconChainTypes>( async fn fetch_and_process_blobs_v1<T: BeaconChainTypes>( chain_adapter: FetchBlobsBeaconAdapter<T>, block_root: Hash256, - block: Arc<SignedBeaconBlock<T::EthSpec>>, + header: &PartialDataColumnHeader<T::EthSpec>, versioned_hashes: Vec<VersionedHash>, publish_fn: impl Fn(EngineGetBlobsOutput<T>) + Send + Sized, ) -> Result<Option<AvailabilityProcessingStatus>, FetchEngineBlobError> { @@ -183,20 +173,14 @@ async fn fetch_and_process_blobs_v1<T: BeaconChainTypes>( return Ok(None); } - let (signed_block_header, kzg_commitments_proof) = block - .signed_block_header_and_kzg_commitments_proof() - .map_err(FetchEngineBlobError::BeaconStateError)?; + let mut blob_sidecar_list = build_blob_sidecars(header, response)?; - let mut blob_sidecar_list = build_blob_sidecars( - &block, - response, - signed_block_header, - &kzg_commitments_proof, - )?; + let observation_key = ObservationKey::new_proposer_key( + header.signed_block_header.message.proposer_index, + header.slot(), + ); - if let Some(observed_blobs) = - chain_adapter.blobs_known_for_proposal(block.message().proposer_index(), block.slot()) - { + if let Some(observed_blobs) = chain_adapter.blobs_known_for_observation_key(observation_key) { blob_sidecar_list.retain(|blob| !observed_blobs.contains(&blob.blob_index())); if blob_sidecar_list.is_empty() { debug!( @@ -225,7 +209,7 @@ async fn fetch_and_process_blobs_v1<T: BeaconChainTypes>( let availability_processing_status = chain_adapter .process_engine_blobs( - block.slot(), + header.slot(), block_root, EngineGetBlobsOutput::Blobs(blob_sidecar_list), ) @@ -235,24 +219,53 @@ async fn fetch_and_process_blobs_v1<T: BeaconChainTypes>( } #[instrument(skip_all, level = "debug")] -async fn fetch_and_process_blobs_v2<T: BeaconChainTypes>( +async fn fetch_and_process_blobs_v2_or_v3<T: BeaconChainTypes>( chain_adapter: FetchBlobsBeaconAdapter<T>, block_root: Hash256, - block: Arc<SignedBeaconBlock<T::EthSpec>>, + header: Arc<PartialDataColumnHeader<T::EthSpec>>, versioned_hashes: Vec<VersionedHash>, custody_columns_indices: &[ColumnIndex], publish_fn: impl Fn(EngineGetBlobsOutput<T>) + Send + 'static, ) -> Result<Option<AvailabilityProcessingStatus>, FetchEngineBlobError> { let num_expected_blobs = versioned_hashes.len(); + let slot = header.slot(); metrics::observe(&metrics::BLOBS_FROM_EL_EXPECTED, num_expected_blobs as f64); - debug!(num_expected_blobs, "Fetching blobs from the EL"); - let response = chain_adapter - .get_blobs_v2(versioned_hashes) - .await - .inspect_err(|_| { - inc_counter(&metrics::BLOBS_FROM_EL_ERROR_TOTAL); - })?; + + let get_blobs_v3 = chain_adapter.supports_get_blobs_v3().await?; + let response = if get_blobs_v3 { + debug!(num_expected_blobs, "Fetching available blobs from the EL"); + // Track request count and duration for standardized metrics + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V3_REQUESTS_TOTAL); + let _timer = + metrics::start_timer(&metrics::BEACON_ENGINE_GET_BLOBS_V3_REQUEST_DURATION_SECONDS); + + chain_adapter + .get_blobs_v3(versioned_hashes) + .await + .inspect_err(|_| { + inc_counter(&metrics::BLOBS_FROM_EL_ERROR_TOTAL); + })? + } else { + debug!(num_expected_blobs, "Fetching all blobs from the EL"); + + // Track request count and duration for standardized metrics + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V2_REQUESTS_TOTAL); + let _timer = + metrics::start_timer(&metrics::BEACON_ENGINE_GET_BLOBS_V2_REQUEST_DURATION_SECONDS); + + let response = chain_adapter + .get_blobs_v2(versioned_hashes) + .await + .inspect_err(|_| { + inc_counter(&metrics::BLOBS_FROM_EL_ERROR_TOTAL); + })?; + + // Track successful response + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V2_RESPONSES_TOTAL); + + response.map(|vec| vec.into_iter().map(Some).collect()) + }; let Some(blobs_and_proofs) = response else { debug!(num_expected_blobs, "No blobs fetched from the EL"); @@ -260,32 +273,35 @@ async fn fetch_and_process_blobs_v2<T: BeaconChainTypes>( return Ok(None); }; - let (blobs, proofs): (Vec<_>, Vec<_>) = blobs_and_proofs - .into_iter() - .map(|blob_and_proof| { - let BlobAndProofV2 { blob, proofs } = blob_and_proof; - (blob, proofs) - }) - .unzip(); - - let num_fetched_blobs = blobs.len(); + let num_fetched_blobs = blobs_and_proofs.iter().filter(|opt| opt.is_some()).count(); metrics::observe(&metrics::BLOBS_FROM_EL_RECEIVED, num_fetched_blobs as f64); if num_fetched_blobs != num_expected_blobs { - // This scenario is not supposed to happen if the EL is spec compliant. - // It should either return all requested blobs or none, but NOT partial responses. - // If we attempt to compute columns with partial blobs, we'd end up with invalid columns. - warn!( - num_fetched_blobs, - num_expected_blobs, "The EL did not return all requested blobs" - ); - inc_counter(&metrics::BLOBS_FROM_EL_MISS_TOTAL); - return Ok(None); + if !get_blobs_v3 { + // This scenario is not supposed to happen if the EL is spec compliant. + // It should either return all requested blobs or none, but NOT partial responses. + // If we attempt to compute columns with partial blobs, we'd end up with invalid columns. + warn!( + num_fetched_blobs, + num_expected_blobs, "The EL did not return all requested blobs" + ); + inc_counter(&metrics::BLOBS_FROM_EL_MISS_TOTAL); + return Ok(None); + } else { + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V3_PARTIAL_RESPONSES_TOTAL); + debug!( + num_fetched_blobs, + num_expected_blobs, "Blobs partially received from the EL" + ); + } + } else { + debug!(num_fetched_blobs, "All blobs received from the EL"); + inc_counter(&metrics::BLOBS_FROM_EL_HIT_TOTAL); + if get_blobs_v3 { + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V3_COMPLETE_RESPONSES_TOTAL); + } } - debug!(num_fetched_blobs, "All expected blobs received from the EL"); - inc_counter(&metrics::BLOBS_FROM_EL_HIT_TOTAL); - if chain_adapter.fork_choice_contains_block(&block_root) { // Avoid computing columns if the block has already been imported. debug!( @@ -299,9 +315,8 @@ async fn fetch_and_process_blobs_v2<T: BeaconChainTypes>( let custody_columns_to_import = compute_custody_columns_to_import( &chain_adapter, block_root, - block.clone(), - blobs, - proofs, + &header, + blobs_and_proofs, custody_columns_indices, ) .await?; @@ -314,20 +329,49 @@ async fn fetch_and_process_blobs_v2<T: BeaconChainTypes>( return Ok(None); } - // Up until this point we have not observed the data columns in the gossip cache, which allows - // them to arrive independently while this function is running. In publish_fn we will observe - // them and then publish any columns that had not already been observed. - publish_fn(EngineGetBlobsOutput::CustodyColumns( - custody_columns_to_import.clone(), - )); + let full_columns = match chain_adapter.partial_assembler() { + Some(assembler) => { + // Initialize the partial assembler with the columns from the engine and return any full + // columns for publishing + assembler + .merge_partials(block_root, custody_columns_to_import, header) + .ok_or_else(|| { + FetchEngineBlobError::InternalError( + "Failed to merge partials into assembler".to_string(), + ) + })? + .full_columns + } + None => { + // Partial columns are disabled, so let's try to directly convert the columns we got + // from the EL into full columns. + custody_columns_to_import + .into_iter() + .filter_map(|col| col.try_into_full(&header)) + .collect() + } + }; - let availability_processing_status = chain_adapter - .process_engine_blobs( - block.slot(), - block_root, - EngineGetBlobsOutput::CustodyColumns(custody_columns_to_import), - ) - .await?; + // Publish complete columns + if !full_columns.is_empty() { + publish_fn(EngineGetBlobsOutput::CustodyColumns(full_columns.clone())); + } + // We publish all partials at the calling site, regardless of result, as previous publishs + // have been blocked, waiting for the results of this call + + // Process complete columns through DA checker + let availability_processing_status = if !full_columns.is_empty() { + chain_adapter + .process_engine_blobs( + slot, + block_root, + EngineGetBlobsOutput::CustodyColumns(full_columns), + ) + .await? + } else { + // No complete columns yet, still missing components + AvailabilityProcessingStatus::MissingComponents(slot, block_root) + }; Ok(Some(availability_processing_status)) } @@ -336,30 +380,34 @@ async fn fetch_and_process_blobs_v2<T: BeaconChainTypes>( async fn compute_custody_columns_to_import<T: BeaconChainTypes>( chain_adapter: &Arc<FetchBlobsBeaconAdapter<T>>, block_root: Hash256, - block: Arc<SignedBeaconBlock<T::EthSpec, FullPayload<T::EthSpec>>>, - blobs: Vec<Blob<T::EthSpec>>, - proofs: Vec<KzgProofs<T::EthSpec>>, + header: &PartialDataColumnHeader<T::EthSpec>, + blobs_and_proofs: Vec<BlobAndProofV3<T::EthSpec>>, custody_columns_indices: &[ColumnIndex], -) -> Result<Vec<KzgVerifiedCustodyDataColumn<T::EthSpec>>, FetchEngineBlobError> { +) -> Result<Vec<KzgVerifiedCustodyPartialDataColumn<T::EthSpec>>, FetchEngineBlobError> { let kzg = chain_adapter.kzg().clone(); let spec = chain_adapter.spec().clone(); let chain_adapter_cloned = chain_adapter.clone(); let custody_columns_indices = custody_columns_indices.to_vec(); - let current_span = Span::current(); + let header = header.clone(); chain_adapter .executor() .spawn_blocking_handle( move || { - let _guard = current_span.enter(); let mut timer = metrics::start_timer_vec( &metrics::DATA_COLUMN_SIDECAR_COMPUTATION, - &[&blobs.len().to_string()], + &[&blobs_and_proofs.len().to_string()], ); - let blob_refs = blobs.iter().collect::<Vec<_>>(); - let cell_proofs = proofs.into_iter().flatten().collect(); + let blob_and_proof_refs = blobs_and_proofs + .iter() + .map(|option| { + option + .as_ref() + .map(|BlobAndProofV2 { blob, proofs }| (blob, proofs.as_ref())) + }) + .collect::<Vec<_>>(); let data_columns_result = - blobs_to_data_column_sidecars(&blob_refs, cell_proofs, &block, &kzg, &spec) + blobs_to_partial_data_columns(blob_and_proof_refs, &header, &kzg, &spec) .discard_timer_on_break(&mut timer); drop(timer); @@ -372,8 +420,10 @@ async fn compute_custody_columns_to_import<T: BeaconChainTypes>( .into_iter() .filter(|col| custody_columns_indices.contains(&col.index)) .map(|col| { - KzgVerifiedCustodyDataColumn::from_asserted_custody( - KzgVerifiedDataColumn::from_execution_verified(col), + KzgVerifiedCustodyPartialDataColumn::from_asserted_custody( + KzgVerifiedPartialDataColumn::from_execution_verified( + Arc::new(col), + ), ) }) .collect::<Vec<_>>() @@ -381,9 +431,12 @@ async fn compute_custody_columns_to_import<T: BeaconChainTypes>( .map_err(FetchEngineBlobError::DataColumnSidecarError)?; // Only consider columns that are not already observed on gossip. - if let Some(observed_columns) = chain_adapter_cloned.data_column_known_for_proposal( - ProposalKey::new(block.message().proposer_index(), block.slot()), - ) { + let observation_key = + ObservationKey::from_partial_column_header(&header, block_root, &spec); + + if let Some(observed_columns) = + chain_adapter_cloned.data_column_known_for_observation_key(observation_key) + { custody_columns.retain(|col| !observed_columns.contains(&col.index())); if custody_columns.is_empty() { return Ok(vec![]); @@ -410,10 +463,8 @@ async fn compute_custody_columns_to_import<T: BeaconChainTypes>( } fn build_blob_sidecars<E: EthSpec>( - block: &Arc<SignedBeaconBlock<E, FullPayload<E>>>, + header: &PartialDataColumnHeader<E>, response: Vec<Option<BlobAndProofV1<E>>>, - signed_block_header: SignedBeaconBlockHeader, - kzg_commitments_inclusion_proof: &FixedVector<Hash256, E::KzgCommitmentsInclusionProofDepth>, ) -> Result<Vec<KzgVerifiedBlob<E>>, FetchEngineBlobError> { let mut sidecars = vec![]; for (index, blob_and_proof) in response @@ -424,9 +475,7 @@ fn build_blob_sidecars<E: EthSpec>( let blob_sidecar = BlobSidecar::new_with_existing_proof( index, blob_and_proof.blob, - block, - signed_block_header.clone(), - kzg_commitments_inclusion_proof, + header.clone(), blob_and_proof.proof, ) .map_err(FetchEngineBlobError::BlobSidecarError)?; diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index cbe2f78fbd..ef282a3eaa 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -3,12 +3,14 @@ use crate::fetch_blobs::fetch_blobs_beacon_adapter::MockFetchBlobsBeaconAdapter; use crate::fetch_blobs::{ EngineGetBlobsOutput, FetchEngineBlobError, fetch_and_process_engine_blobs_inner, }; +use crate::partial_data_column_assembler::PartialDataColumnAssembler; use crate::test_utils::{EphemeralHarnessType, get_kzg}; use bls::Signature; use eth2::types::BlobsBundle; use execution_layer::json_structures::{BlobAndProof, BlobAndProofV1, BlobAndProofV2}; use execution_layer::test_utils::generate_blobs; use maplit::hashset; +use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use task_executor::test_utils::TestRuntime; use types::{ @@ -21,11 +23,11 @@ type T = EphemeralHarnessType<E>; mod get_blobs_v2 { use super::*; - use types::ColumnIndex; + use types::{ColumnIndex, PartialDataColumnHeader}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_no_blobs_in_block() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, _s) = mock_publish_fn(); let block = SignedBeaconBlock::<E>::Fulu(SignedBeaconBlockFulu { message: BeaconBlockFulu::empty(mock_adapter.spec()), @@ -41,7 +43,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - Arc::new(block), + Arc::new((&block).try_into().unwrap()), &custody_columns, publish_fn, ) @@ -53,7 +55,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_no_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, _) = mock_publish_fn(); let (block, _blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -66,7 +68,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -78,7 +80,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_partial_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, mut blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -94,7 +96,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -111,7 +113,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_block_imported_after_el_response() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -127,7 +129,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -144,7 +146,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_no_new_columns_to_import() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -156,7 +158,7 @@ mod get_blobs_v2 { mock_fork_choice_contains_block(&mut mock_adapter, vec![]); // All data columns already seen on gossip mock_adapter - .expect_data_column_known_for_proposal() + .expect_data_column_known_for_observation_key() .returning(|_| Some(hashset![0, 1, 2])); // No blobs should be processed mock_adapter.expect_process_engine_blobs().times(0); @@ -166,7 +168,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -184,7 +186,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_success() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -193,7 +195,7 @@ mod get_blobs_v2 { mock_get_blobs_v2_response(&mut mock_adapter, Some(blobs_and_proofs)); mock_fork_choice_contains_block(&mut mock_adapter, vec![]); mock_adapter - .expect_data_column_known_for_proposal() + .expect_data_column_known_for_observation_key() .returning(|_| None); mock_adapter .expect_cached_data_column_indexes() @@ -208,7 +210,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -253,17 +255,19 @@ mod get_blobs_v1 { use super::*; use crate::block_verification_types::AsBlock; use std::collections::HashSet; - use types::ColumnIndex; + use types::{ColumnIndex, FullPayload, PartialDataColumnHeader}; const ELECTRA_FORK: ForkName = ForkName::Electra; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_no_blobs_in_block() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let spec = mock_adapter.spec(); let (publish_fn, _s) = mock_publish_fn(); - let block_no_blobs = - SignedBeaconBlock::from_block(BeaconBlock::empty(spec), Signature::empty()); + let block_no_blobs = SignedBeaconBlock::<E, FullPayload<E>>::from_block( + BeaconBlock::empty(spec), + Signature::empty(), + ); let block_root = block_no_blobs.canonical_root(); // Expectations: engine fetch blobs should not be triggered @@ -274,7 +278,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - Arc::new(block_no_blobs), + Arc::new(PartialDataColumnHeader::try_from(&block_no_blobs).unwrap()), &custody_columns, publish_fn, ) @@ -287,7 +291,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_no_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, _) = mock_publish_fn(); let (block, _blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -301,7 +305,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -314,7 +318,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_partial_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let blob_count = 2; let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, blob_count); @@ -332,8 +336,8 @@ mod get_blobs_v1 { .expect_cached_blob_indexes() .returning(|_| None); mock_adapter - .expect_blobs_known_for_proposal() - .returning(|_, _| None); + .expect_blobs_known_for_observation_key() + .returning(|_| None); // Returned blobs should be processed mock_process_engine_blobs_result( &mut mock_adapter, @@ -347,7 +351,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -372,7 +376,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_block_imported_after_el_response() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -387,7 +391,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -405,7 +409,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_no_new_blobs_to_import() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -427,15 +431,15 @@ mod get_blobs_v1 { .expect_cached_blob_indexes() .returning(|_| None); mock_adapter - .expect_blobs_known_for_proposal() - .returning(move |_, _| Some(all_blob_indices.clone())); + .expect_blobs_known_for_observation_key() + .returning(move |_| Some(all_blob_indices.clone())); // **WHEN**: Trigger `fetch_blobs` on the block let custody_columns: [ColumnIndex; 3] = [0, 1, 2]; let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -453,7 +457,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_success() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let blob_count = 2; let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, blob_count); @@ -467,8 +471,8 @@ mod get_blobs_v1 { .expect_cached_blob_indexes() .returning(|_| None); mock_adapter - .expect_blobs_known_for_proposal() - .returning(|_, _| None); + .expect_blobs_known_for_observation_key() + .returning(|_| None); mock_process_engine_blobs_result( &mut mock_adapter, Ok(AvailabilityProcessingStatus::Imported(block_root)), @@ -479,7 +483,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -606,10 +610,11 @@ fn mock_publish_fn() -> ( (publish_fn, captured_args) } -fn mock_beacon_adapter(fork_name: ForkName) -> MockFetchBlobsBeaconAdapter<T> { +fn mock_beacon_adapter(fork_name: ForkName, get_blobs_v3: bool) -> MockFetchBlobsBeaconAdapter<T> { let test_runtime = TestRuntime::default(); let spec = Arc::new(fork_name.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); + let partial_assembler = PartialDataColumnAssembler::new(NonZeroUsize::new(32).unwrap()); let mut mock_adapter = MockFetchBlobsBeaconAdapter::default(); mock_adapter.expect_spec().return_const(spec.clone()); @@ -618,4 +623,10 @@ fn mock_beacon_adapter(fork_name: ForkName) -> MockFetchBlobsBeaconAdapter<T> { .expect_executor() .return_const(test_runtime.task_executor.clone()); mock_adapter + .expect_supports_get_blobs_v3() + .returning(move || Ok(get_blobs_v3)); + mock_adapter + .expect_partial_assembler() + .return_const(Some(Arc::new(partial_assembler))); + mock_adapter } diff --git a/beacon_node/beacon_chain/src/fork_revert.rs b/beacon_node/beacon_chain/src/fork_revert.rs deleted file mode 100644 index 4db79790d3..0000000000 --- a/beacon_node/beacon_chain/src/fork_revert.rs +++ /dev/null @@ -1,204 +0,0 @@ -use crate::{BeaconForkChoiceStore, BeaconSnapshot}; -use fork_choice::{ForkChoice, PayloadVerificationStatus}; -use itertools::process_results; -use state_processing::state_advance::complete_state_advance; -use state_processing::{ - ConsensusContext, VerifyBlockRoot, per_block_processing, - per_block_processing::BlockSignatureStrategy, -}; -use std::sync::Arc; -use std::time::Duration; -use store::{HotColdDB, ItemStore, iter::ParentRootBlockIterator}; -use tracing::{info, warn}; -use types::{BeaconState, ChainSpec, EthSpec, ForkName, Hash256, SignedBeaconBlock, Slot}; - -const CORRUPT_DB_MESSAGE: &str = "The database could be corrupt. Check its file permissions or \ - consider deleting it by running with the --purge-db flag."; - -/// Revert the head to the last block before the most recent hard fork. -/// -/// This function is destructive and should only be used if there is no viable alternative. It will -/// cause the reverted blocks and states to be completely forgotten, lying dormant in the database -/// forever. -/// -/// Return the `(head_block_root, head_block)` that should be used post-reversion. -pub fn revert_to_fork_boundary<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>>( - current_slot: Slot, - head_block_root: Hash256, - store: Arc<HotColdDB<E, Hot, Cold>>, - spec: &ChainSpec, -) -> Result<(Hash256, SignedBeaconBlock<E>), String> { - let current_fork = spec.fork_name_at_slot::<E>(current_slot); - let fork_epoch = spec - .fork_epoch(current_fork) - .ok_or_else(|| format!("Current fork '{}' never activates", current_fork))?; - - if current_fork == ForkName::Base { - return Err(format!( - "Cannot revert to before phase0 hard fork. {}", - CORRUPT_DB_MESSAGE - )); - } - - warn!( - target_fork = %current_fork, - %fork_epoch, - "Reverting invalid head block" - ); - let block_iter = ParentRootBlockIterator::fork_tolerant(&store, head_block_root); - - let (block_root, blinded_block) = process_results(block_iter, |mut iter| { - iter.find_map(|(block_root, block)| { - if block.slot() < fork_epoch.start_slot(E::slots_per_epoch()) { - Some((block_root, block)) - } else { - info!( - ?block_root, - slot = %block.slot(), - "Reverting block" - ); - None - } - }) - }) - .map_err(|e| { - format!( - "Error fetching blocks to revert: {:?}. {}", - e, CORRUPT_DB_MESSAGE - ) - })? - .ok_or_else(|| format!("No pre-fork blocks found. {}", CORRUPT_DB_MESSAGE))?; - - let block = store - .make_full_block(&block_root, blinded_block) - .map_err(|e| format!("Unable to add payload to new head block: {:?}", e))?; - - Ok((block_root, block)) -} - -/// Reset fork choice to the finalized checkpoint of the supplied head state. -/// -/// The supplied `head_block_root` should correspond to the most recently applied block on -/// `head_state`. -/// -/// This function avoids quirks of fork choice initialization by replaying all of the blocks from -/// the checkpoint to the head. -/// -/// See this issue for details: https://github.com/ethereum/consensus-specs/issues/2566 -/// -/// It will fail if the finalized state or any of the blocks to replay are unavailable. -/// -/// WARNING: this function is destructive and causes fork choice to permanently forget all -/// chains other than the chain leading to `head_block_root`. It should only be used in extreme -/// circumstances when there is no better alternative. -pub fn reset_fork_choice_to_finalization<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>>( - head_block_root: Hash256, - head_state: &BeaconState<E>, - store: Arc<HotColdDB<E, Hot, Cold>>, - current_slot: Option<Slot>, - spec: &ChainSpec, -) -> Result<ForkChoice<BeaconForkChoiceStore<E, Hot, Cold>, E>, String> { - // Fetch finalized block. - let finalized_checkpoint = head_state.finalized_checkpoint(); - let finalized_block_root = finalized_checkpoint.root; - let finalized_block = store - .get_full_block(&finalized_block_root) - .map_err(|e| format!("Error loading finalized block: {:?}", e))? - .ok_or_else(|| { - format!( - "Finalized block missing for revert: {:?}", - finalized_block_root - ) - })?; - - // Advance finalized state to finalized epoch (to handle skipped slots). - let finalized_state_root = finalized_block.state_root(); - // The enshrined finalized state should be in the state cache. - let mut finalized_state = store - .get_state(&finalized_state_root, Some(finalized_block.slot()), true) - .map_err(|e| format!("Error loading finalized state: {:?}", e))? - .ok_or_else(|| { - format!( - "Finalized block state missing from database: {:?}", - finalized_state_root - ) - })?; - let finalized_slot = finalized_checkpoint.epoch.start_slot(E::slots_per_epoch()); - complete_state_advance( - &mut finalized_state, - Some(finalized_state_root), - finalized_slot, - spec, - ) - .map_err(|e| { - format!( - "Error advancing finalized state to finalized epoch: {:?}", - e - ) - })?; - let finalized_snapshot = BeaconSnapshot { - beacon_block_root: finalized_block_root, - beacon_block: Arc::new(finalized_block), - beacon_state: finalized_state, - }; - - let fc_store = - BeaconForkChoiceStore::get_forkchoice_store(store.clone(), finalized_snapshot.clone()) - .map_err(|e| format!("Unable to reset fork choice store for revert: {e:?}"))?; - - let mut fork_choice = ForkChoice::from_anchor( - fc_store, - finalized_block_root, - &finalized_snapshot.beacon_block, - &finalized_snapshot.beacon_state, - current_slot, - spec, - ) - .map_err(|e| format!("Unable to reset fork choice for revert: {:?}", e))?; - - // Replay blocks from finalized checkpoint back to head. - // We do not replay attestations presently, relying on the absence of other blocks - // to guarantee `head_block_root` as the head. - let blocks = store - .load_blocks_to_replay(finalized_slot + 1, head_state.slot(), head_block_root) - .map_err(|e| format!("Error loading blocks to replay for fork choice: {:?}", e))?; - - let mut state = finalized_snapshot.beacon_state; - for block in blocks { - complete_state_advance(&mut state, None, block.slot(), spec) - .map_err(|e| format!("State advance failed: {:?}", e))?; - - let mut ctxt = ConsensusContext::new(block.slot()) - .set_proposer_index(block.message().proposer_index()); - per_block_processing( - &mut state, - &block, - BlockSignatureStrategy::NoVerification, - VerifyBlockRoot::True, - &mut ctxt, - spec, - ) - .map_err(|e| format!("Error replaying block: {:?}", e))?; - - // Setting this to unverified is the safest solution, since we don't have a way to - // retro-actively determine if they were valid or not. - // - // This scenario is so rare that it seems OK to double-verify some blocks. - let payload_verification_status = PayloadVerificationStatus::Optimistic; - - fork_choice - .on_block( - block.slot(), - block.message(), - block.canonical_root(), - // Reward proposer boost. We are reinforcing the canonical chain. - Duration::from_secs(0), - &state, - payload_verification_status, - spec, - ) - .map_err(|e| format!("Error applying replayed block to fork choice: {:?}", e))?; - } - - Ok(fork_choice) -} diff --git a/beacon_node/beacon_chain/src/graffiti_calculator.rs b/beacon_node/beacon_chain/src/graffiti_calculator.rs index 85470715c9..403873cc00 100644 --- a/beacon_node/beacon_chain/src/graffiti_calculator.rs +++ b/beacon_node/beacon_chain/src/graffiti_calculator.rs @@ -446,7 +446,7 @@ mod tests { DEFAULT_CLIENT_VERSION.code, mock_commit .strip_prefix("0x") - .unwrap_or("&mock_commit") + .unwrap_or(&mock_commit) .get(0..4) .expect("should get first 2 bytes in hex"), "LH", @@ -459,7 +459,7 @@ mod tests { DEFAULT_CLIENT_VERSION.code, mock_commit .strip_prefix("0x") - .unwrap_or("&mock_commit") + .unwrap_or(&mock_commit) .get(0..2) .expect("should get first 2 bytes in hex"), "LH", diff --git a/beacon_node/beacon_chain/src/historical_blocks.rs b/beacon_node/beacon_chain/src/historical_blocks.rs index 91b0f12cbb..bfda52558e 100644 --- a/beacon_node/beacon_chain/src/historical_blocks.rs +++ b/beacon_node/beacon_chain/src/historical_blocks.rs @@ -12,7 +12,7 @@ use std::time::Duration; use store::metadata::DataColumnInfo; use store::{AnchorInfo, BlobInfo, DBColumn, Error as StoreError, KeyValueStore, KeyValueStoreOp}; use strum::IntoStaticStr; -use tracing::{debug, instrument}; +use tracing::{debug, debug_span, instrument}; use types::{Hash256, Slot}; /// Use a longer timeout on the pubkey cache. @@ -157,23 +157,16 @@ impl<T: BeaconChainTypes> BeaconChain<T> { } match &block_data { - AvailableBlockData::NoData => {} - AvailableBlockData::Blobs(..) => { - new_oldest_blob_slot = Some(block.slot()); - } + AvailableBlockData::NoData => (), + AvailableBlockData::Blobs(_) => new_oldest_blob_slot = Some(block.slot()), AvailableBlockData::DataColumns(_) => { - new_oldest_data_column_slot = Some(block.slot()); + new_oldest_data_column_slot = Some(block.slot()) } } // Store the blobs or data columns too - if let Some(op) = self - .get_blobs_or_columns_store_op(block_root, block.slot(), block_data) - .map_err(|e| { - HistoricalBlockError::StoreError(StoreError::DBError { - message: format!("get_blobs_or_columns_store_op error {e:?}"), - }) - })? + if let Some(op) = + self.get_blobs_or_columns_store_op(block_root, block.slot(), block_data) { blob_batch.extend(self.store.convert_to_kv_batch(vec![op])?); } @@ -258,9 +251,18 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // Write the I/O batches to disk, writing the blocks themselves first, as it's better // for the hot DB to contain extra blocks than for the cold DB to point to blocks that // do not exist. - self.store.blobs_db.do_atomically(blob_batch)?; - self.store.hot_db.do_atomically(hot_batch)?; - self.store.cold_db.do_atomically(cold_batch)?; + { + let _span = debug_span!("backfill_write_blobs_db").entered(); + self.store.blobs_db.do_atomically(blob_batch)?; + } + { + let _span = debug_span!("backfill_write_hot_db").entered(); + self.store.hot_db.do_atomically(hot_batch)?; + } + { + let _span = debug_span!("backfill_write_cold_db").entered(); + self.store.cold_db.do_atomically(cold_batch)?; + } let mut anchor_and_blob_batch = Vec::with_capacity(3); @@ -307,10 +309,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> { // If backfill has completed and the chain is configured to reconstruct historic states, // send a message to the background migrator instructing it to begin reconstruction. // This can only happen if we have backfilled all the way to genesis. - if backfill_complete - && self.genesis_backfill_slot == Slot::new(0) - && self.config.reconstruct_historic_states - { + if backfill_complete && self.genesis_backfill_slot == Slot::new(0) && self.config.archive { self.store_migrator.process_reconstruction(); } diff --git a/beacon_node/beacon_chain/src/historical_data_columns.rs b/beacon_node/beacon_chain/src/historical_data_columns.rs index 6cf947adcb..d6977d9985 100644 --- a/beacon_node/beacon_chain/src/historical_data_columns.rs +++ b/beacon_node/beacon_chain/src/historical_data_columns.rs @@ -61,12 +61,12 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let unique_column_indices = historical_data_column_sidecar_list .iter() - .map(|item| item.index) + .map(|item| *item.index()) .collect::<HashSet<_>>(); let mut slot_and_column_index_to_data_columns = historical_data_column_sidecar_list .iter() - .map(|data_column| ((data_column.slot(), data_column.index), data_column)) + .map(|data_column| ((data_column.slot(), *data_column.index()), data_column)) .collect::<HashMap<_, _>>(); let forward_blocks_iter = self @@ -80,13 +80,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> { let (block_root, slot) = block_iter_result .map_err(|e| HistoricalDataColumnError::BeaconChainError(Box::new(e)))?; + let fork_name = self.spec.fork_name_at_slot::<T::EthSpec>(slot); for column_index in unique_column_indices.clone() { if let Some(data_column) = slot_and_column_index_to_data_columns.remove(&(slot, column_index)) { if self .store - .get_data_column(&block_root, &data_column.index)? + .get_data_column(&block_root, data_column.index(), fork_name)? .is_some() { continue; diff --git a/beacon_node/beacon_chain/src/inclusion_list_verification.rs b/beacon_node/beacon_chain/src/inclusion_list_verification.rs index 763e04a3f5..5ca9e6672c 100644 --- a/beacon_node/beacon_chain/src/inclusion_list_verification.rs +++ b/beacon_node/beacon_chain/src/inclusion_list_verification.rs @@ -2,10 +2,10 @@ use std::time::Duration; use crate::{ BeaconChain, BeaconChainError, BeaconChainTypes, - validator_monitor::{get_slot_delay_ms, timestamp_now}, + validator_monitor::get_slot_delay_ms, }; -use slot_clock::SlotClock; +use slot_clock::{SlotClock, timestamp_now}; use strum::AsRefStr; use tree_hash::TreeHash; use types::{Domain, SignedInclusionList, SignedRoot, Slot}; @@ -67,7 +67,7 @@ impl<T: BeaconChainTypes> GossipVerifiedInclusionList<T> { }); } - let attestation_deadline = Duration::from_secs(chain.spec.seconds_per_slot / 3); + let attestation_deadline = chain.spec.get_slot_duration() / 3; let inclusion_list_delay_total = get_slot_delay_ms(timestamp_now(), message_slot, &chain.slot_clock); diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs new file mode 100644 index 0000000000..b365f37a0a --- /dev/null +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -0,0 +1,56 @@ +//! Beacon chain database invariant checks. +//! +//! Builds the `InvariantContext` from beacon chain state and delegates all checks +//! to `HotColdDB::check_invariants`. + +use crate::BeaconChain; +use crate::beacon_chain::BeaconChainTypes; +use store::invariants::{InvariantCheckResult, InvariantContext}; + +impl<T: BeaconChainTypes> BeaconChain<T> { + /// Run all database invariant checks. + /// + /// Collects context from fork choice, state cache, custody columns, and pubkey cache, + /// then delegates to the store-level `check_invariants` method. + pub fn check_database_invariants(&self) -> Result<InvariantCheckResult, store::Error> { + let fork_choice_blocks = { + let fc = self.canonical_head.fork_choice_read_lock(); + let proto_array = fc.proto_array().core_proto_array(); + proto_array + .nodes + .iter() + .filter(|node| { + // Only check blocks that are descendants of the finalized checkpoint. + // Pruned non-canonical fork blocks may linger in the proto-array but + // are legitimately absent from the database. + fc.is_finalized_checkpoint_or_descendant(node.root()) + }) + .map(|node| (node.root(), node.slot())) + .collect() + }; + + let custody_context = self.data_availability_checker.custody_context(); + + let ctx = InvariantContext { + fork_choice_blocks, + state_cache_roots: self.store.state_cache.lock().state_roots(), + custody_columns: custody_context + .custody_columns_for_epoch(None, &self.spec) + .to_vec(), + pubkey_cache_pubkeys: { + let cache = self.validator_pubkey_cache.read(); + (0..cache.len()) + .filter_map(|i| { + cache.get(i).map(|pk| { + use store::StoreItem; + crate::validator_pubkey_cache::DatabasePubkey::from_pubkey(pk) + .as_store_bytes() + }) + }) + .collect() + }, + }; + + self.store.check_invariants(&ctx) + } +} diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index 334124419b..b05a896777 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -1,31 +1,34 @@ use kzg::{ - Blob as KzgBlob, Bytes48, Cell as KzgCell, CellRef as KzgCellRef, CellsAndKzgProofs, - Error as KzgError, Kzg, KzgBlobRef, + Cell as KzgCell, CellRef as KzgCellRef, CellsAndKzgProofs, Error as KzgError, Kzg, KzgBlobRef, }; use rayon::prelude::*; use ssz_types::{FixedVector, VariableList}; use std::sync::Arc; use tracing::instrument; -use types::beacon_block_body::KzgCommitments; -use types::data_column_sidecar::{Cell, DataColumn, DataColumnSidecarError}; +use tree_hash::TreeHash; +use types::data::{ + Cell, CellBitmap, ColumnIndex, DataColumn, DataColumnSidecarError, PartialDataColumn, + PartialDataColumnHeader, PartialDataColumnSidecarRef, +}; +use types::kzg_ext::KzgCommitments; use types::{ - Blob, BlobSidecar, BlobSidecarList, ChainSpec, DataColumnSidecar, DataColumnSidecarList, - EthSpec, Hash256, KzgCommitment, KzgProof, SignedBeaconBlock, SignedBeaconBlockHeader, - SignedBlindedBeaconBlock, + Blob, BlobSidecar, BlobSidecarList, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, + DataColumnSidecarGloas, DataColumnSidecarList, EthSpec, Hash256, KzgCommitment, KzgProof, + SignedBeaconBlock, SignedBeaconBlockHeader, SignedBlindedBeaconBlock, Slot, }; -/// Converts a blob ssz List object to an array to be used with the kzg -/// crypto library. -fn ssz_blob_to_crypto_blob<E: EthSpec>(blob: &Blob<E>) -> Result<KzgBlob, KzgError> { - KzgBlob::from_bytes(blob.as_ref()).map_err(Into::into) +/// Converts a blob ssz FixedVector to a reference to a fixed-size array +/// to be used with `rust_eth_kzg`. +fn ssz_blob_to_kzg_blob_ref<E: EthSpec>(blob: &Blob<E>) -> Result<KzgBlobRef<'_>, KzgError> { + blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + }) } -fn ssz_blob_to_crypto_blob_boxed<E: EthSpec>(blob: &Blob<E>) -> Result<Box<KzgBlob>, KzgError> { - ssz_blob_to_crypto_blob::<E>(blob).map(Box::new) -} - -/// Converts a cell ssz List object to an array to be used with the kzg -/// crypto library. +/// Converts a cell ssz FixedVector to a reference to a fixed-size array +/// to be used with `rust_eth_kzg`. fn ssz_cell_to_crypto_cell<E: EthSpec>(cell: &Cell<E>) -> Result<KzgCellRef<'_>, KzgError> { let cell_bytes: &[u8] = cell.as_ref(); cell_bytes @@ -41,41 +44,107 @@ pub fn validate_blob<E: EthSpec>( kzg_proof: KzgProof, ) -> Result<(), KzgError> { let _timer = crate::metrics::start_timer(&crate::metrics::KZG_VERIFICATION_SINGLE_TIMES); - let kzg_blob = ssz_blob_to_crypto_blob_boxed::<E>(blob)?; - kzg.verify_blob_kzg_proof(&kzg_blob, kzg_commitment, kzg_proof) + let kzg_blob = ssz_blob_to_kzg_blob_ref::<E>(blob)?; + kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) } -/// Validate a batch of `DataColumnSidecar`. -pub fn validate_data_columns<'a, E: EthSpec, I>( +/// Validate a batch of full `DataColumnSidecar`s. +/// +/// Full columns have all cells present, so we iterate over all cells directly. +pub fn validate_full_data_columns<'a, E: EthSpec>( kzg: &Kzg, - data_column_iter: I, -) -> Result<(), (Option<u64>, KzgError)> -where - I: Iterator<Item = &'a Arc<DataColumnSidecar<E>>> + Clone, -{ + data_column_iter: impl Iterator<Item = &'a Arc<DataColumnSidecar<E>>>, +) -> Result<(), (Option<u64>, KzgError)> { let mut cells = Vec::new(); let mut proofs = Vec::new(); let mut column_indices = Vec::new(); let mut commitments = Vec::new(); for data_column in data_column_iter { - let col_index = data_column.index; + let col_index = *data_column.index(); - if data_column.column.is_empty() { + if data_column.column().is_empty() { return Err((Some(col_index), KzgError::KzgVerificationFailed)); } - for cell in &data_column.column { + for cell in data_column.column() { cells.push(ssz_cell_to_crypto_cell::<E>(cell).map_err(|e| (Some(col_index), e))?); column_indices.push(col_index); } - for &proof in &data_column.kzg_proofs { - proofs.push(Bytes48::from(proof)); + for &proof in data_column.kzg_proofs() { + proofs.push(proof.0); } - for &commitment in &data_column.kzg_commitments { - commitments.push(Bytes48::from(commitment)); + // In Gloas, commitments come from the block's ExecutionPayloadBid, not the sidecar. + // This function requires Fulu sidecars with embedded commitments. + let kzg_commitments = match data_column.as_ref() { + DataColumnSidecar::Fulu(dc) => &dc.kzg_commitments, + DataColumnSidecar::Gloas(_) => { + return Err(( + Some(col_index), + KzgError::InconsistentArrayLength( + "Gloas data columns require commitments from block".to_string(), + ), + )); + } + }; + + for &commitment in kzg_commitments.iter() { + commitments.push(commitment.0); + } + + let expected_len = column_indices.len(); + + // We make this check at each iteration so that the error is attributable to a specific column + if cells.len() != expected_len + || proofs.len() != expected_len + || commitments.len() != expected_len + { + return Err(( + Some(col_index), + KzgError::InconsistentArrayLength("Invalid data column".to_string()), + )); + } + } + + kzg.verify_cell_proof_batch(&cells, &proofs, column_indices, &commitments) +} + +/// Validate a batch of partial `VerifiablePartialDataColumn`s. +/// +/// Partial columns may have missing cells, indicated by a bitmap. We only verify present cells. +pub fn validate_partial_data_columns<'a, E: EthSpec>( + kzg: &Kzg, + data_column_iter: impl Iterator<Item = (ColumnIndex, PartialDataColumnSidecarRef<'a, E>)>, + kzg_commitments: &[KzgCommitment], +) -> Result<(), (Option<u64>, KzgError)> { + let mut cells = Vec::new(); + let mut proofs = Vec::new(); + let mut column_indices = Vec::new(); + let mut commitments = Vec::new(); + + for (col_index, sidecar) in data_column_iter { + if sidecar.column.is_empty() { + return Err((Some(col_index), KzgError::KzgVerificationFailed)); + } + + // Partial columns have a bitmap indicating present cells + // We iterate over the bitmap and only process present cells + let mut present_iterator = sidecar.column.iter().zip(sidecar.kzg_proofs.iter()); + for (present, commitment) in sidecar.cells_present_bitmap.iter().zip(kzg_commitments) { + if present { + let (cell, proof) = present_iterator.next().ok_or(( + Some(col_index), + KzgError::InconsistentArrayLength( + "Partial column has fewer cells than bitmap indicates".to_string(), + ), + ))?; + cells.push(ssz_cell_to_crypto_cell::<E>(cell).map_err(|e| (Some(col_index), e))?); + column_indices.push(col_index); + proofs.push(proof.0); + commitments.push(commitment.0); + } } let expected_len = column_indices.len(); @@ -105,7 +174,7 @@ pub fn validate_blobs<E: EthSpec>( let _timer = crate::metrics::start_timer(&crate::metrics::KZG_VERIFICATION_BATCH_TIMES); let blobs = blobs .into_iter() - .map(|blob| ssz_blob_to_crypto_blob::<E>(blob)) + .map(|blob| ssz_blob_to_kzg_blob_ref::<E>(blob)) .collect::<Result<Vec<_>, KzgError>>()?; kzg.verify_blob_kzg_proof_batch(&blobs, expected_kzg_commitments, kzg_proofs) @@ -117,8 +186,8 @@ pub fn compute_blob_kzg_proof<E: EthSpec>( blob: &Blob<E>, kzg_commitment: KzgCommitment, ) -> Result<KzgProof, KzgError> { - let kzg_blob = ssz_blob_to_crypto_blob_boxed::<E>(blob)?; - kzg.compute_blob_kzg_proof(&kzg_blob, kzg_commitment) + let kzg_blob = ssz_blob_to_kzg_blob_ref::<E>(blob)?; + kzg.compute_blob_kzg_proof(kzg_blob, kzg_commitment) } /// Compute the kzg commitment for a given blob. @@ -126,8 +195,8 @@ pub fn blob_to_kzg_commitment<E: EthSpec>( kzg: &Kzg, blob: &Blob<E>, ) -> Result<KzgCommitment, KzgError> { - let kzg_blob = ssz_blob_to_crypto_blob_boxed::<E>(blob)?; - kzg.blob_to_kzg_commitment(&kzg_blob) + let kzg_blob = ssz_blob_to_kzg_blob_ref::<E>(blob)?; + kzg.blob_to_kzg_commitment(kzg_blob) } /// Compute the kzg proof for a given blob and an evaluation point z. @@ -136,10 +205,9 @@ pub fn compute_kzg_proof<E: EthSpec>( blob: &Blob<E>, z: Hash256, ) -> Result<(KzgProof, Hash256), KzgError> { - let z = z.0.into(); - let kzg_blob = ssz_blob_to_crypto_blob_boxed::<E>(blob)?; - kzg.compute_kzg_proof(&kzg_blob, &z) - .map(|(proof, z)| (proof, Hash256::from_slice(&z.to_vec()))) + let kzg_blob = ssz_blob_to_kzg_blob_ref::<E>(blob)?; + kzg.compute_kzg_proof(kzg_blob, &z.0) + .map(|(proof, z)| (proof, Hash256::from_slice(&z))) } /// Verify a `kzg_proof` for a `kzg_commitment` that evaluating a polynomial at `z` results in `y` @@ -150,7 +218,7 @@ pub fn verify_kzg_proof<E: EthSpec>( z: Hash256, y: Hash256, ) -> Result<bool, KzgError> { - kzg.verify_kzg_proof(kzg_commitment, &z.0.into(), &y.0.into(), kzg_proof) + kzg.verify_kzg_proof(kzg_commitment, &z.0, &y.0, kzg_proof) } /// Build data column sidecars from a signed beacon block and its blobs. @@ -171,7 +239,6 @@ pub fn blobs_to_data_column_sidecars<E: EthSpec>( .body() .blob_kzg_commitments() .map_err(|_err| DataColumnSidecarError::PreDeneb)?; - let kzg_commitments_inclusion_proof = block.message().body().kzg_commitments_merkle_proof()?; let signed_block_header = block.signed_block_header(); if cell_proofs.len() != blobs.len() * E::number_of_columns() { @@ -207,14 +274,95 @@ pub fn blobs_to_data_column_sidecars<E: EthSpec>( }) .collect::<Result<Vec<_>, KzgError>>()?; - build_data_column_sidecars( - kzg_commitments.clone(), - kzg_commitments_inclusion_proof, - signed_block_header, - blob_cells_and_proofs_vec, - spec, - ) - .map_err(DataColumnSidecarError::BuildSidecarFailed) + if block.fork_name_unchecked().gloas_enabled() { + build_data_column_sidecars_gloas( + signed_block_header.message.tree_hash_root(), + block.slot(), + blob_cells_and_proofs_vec, + spec, + ) + .map_err(DataColumnSidecarError::BuildSidecarFailed) + } else { + let kzg_commitments_inclusion_proof = + block.message().body().kzg_commitments_merkle_proof()?; + build_data_column_sidecars_fulu( + kzg_commitments.clone(), + kzg_commitments_inclusion_proof, + signed_block_header, + blob_cells_and_proofs_vec, + spec, + ) + .map_err(DataColumnSidecarError::BuildSidecarFailed) + } +} + +/// Build Gloas data column sidecars from blobs, computing cells and proofs locally. +pub fn blobs_to_data_column_sidecars_gloas<E: EthSpec>( + blobs: &[&Blob<E>], + beacon_block_root: Hash256, + slot: Slot, + kzg: &Kzg, + spec: &ChainSpec, +) -> Result<DataColumnSidecarList<E>, DataColumnSidecarError> { + if blobs.is_empty() { + return Ok(vec![]); + } + + let blob_cells_and_proofs_vec = blobs + .into_par_iter() + .map(|blob| { + let blob = blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + })?; + + kzg.compute_cells_and_proofs(blob) + }) + .collect::<Result<Vec<_>, KzgError>>()?; + + build_data_column_sidecars_gloas(beacon_block_root, slot, blob_cells_and_proofs_vec, spec) + .map_err(DataColumnSidecarError::BuildSidecarFailed) +} + +/// Build data column sidecars from a signed beacon block and its blobs. +#[instrument(skip_all, level = "debug", fields(blob_count = blobs_and_proofs.len()))] +pub fn blobs_to_partial_data_columns<E: EthSpec>( + blobs_and_proofs: Vec<Option<(&Blob<E>, &[KzgProof])>>, + header: &PartialDataColumnHeader<E>, + kzg: &Kzg, + spec: &ChainSpec, +) -> Result<Vec<PartialDataColumn<E>>, DataColumnSidecarError> { + if blobs_and_proofs.is_empty() { + return Ok(vec![]); + } + + let blob_cells_and_proofs_vec = blobs_and_proofs + .into_par_iter() + .map(|maybe_blob_and_proofs| { + let Some((blob, proofs)) = maybe_blob_and_proofs else { + return Ok(None); + }; + + let blob = blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + })?; + + kzg.compute_cells(blob).and_then(|cells| { + let proofs = proofs.try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "proof chunks should have exactly `number_of_columns` proofs: {e:?}" + )) + })?; + Ok(Some((cells, proofs))) + }) + }) + .collect::<Result<Vec<_>, KzgError>>()?; + + build_partial_data_columns(header, blob_cells_and_proofs_vec, spec) + .map_err(DataColumnSidecarError::BuildSidecarFailed) } pub fn compute_cells<E: EthSpec>(blobs: &[&Blob<E>], kzg: &Kzg) -> Result<Vec<KzgCell>, KzgError> { @@ -235,13 +383,20 @@ pub fn compute_cells<E: EthSpec>(blobs: &[&Blob<E>], kzg: &Kzg) -> Result<Vec<Kz Ok(cells_flattened) } -pub(crate) fn build_data_column_sidecars<E: EthSpec>( +pub(crate) fn build_data_column_sidecars_fulu<E: EthSpec>( kzg_commitments: KzgCommitments<E>, kzg_commitments_inclusion_proof: FixedVector<Hash256, E::KzgCommitmentsInclusionProofDepth>, signed_block_header: SignedBeaconBlockHeader, blob_cells_and_proofs_vec: Vec<CellsAndKzgProofs>, spec: &ChainSpec, ) -> Result<DataColumnSidecarList<E>, String> { + if spec + .fork_name_at_slot::<E>(signed_block_header.message.slot) + .gloas_enabled() + { + return Err("Attempting to construct Fulu data columns post-Gloas".to_owned()); + } + let number_of_columns = E::number_of_columns(); let max_blobs_per_block = spec .max_blobs_per_block(signed_block_header.message.slot.epoch(E::slots_per_epoch())) @@ -283,7 +438,7 @@ pub(crate) fn build_data_column_sidecars<E: EthSpec>( .enumerate() .map( |(index, (col, proofs))| -> Result<Arc<DataColumnSidecar<E>>, String> { - Ok(Arc::new(DataColumnSidecar { + Ok(Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { index: index as u64, column: DataColumn::<E>::try_from(col) .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, @@ -292,7 +447,71 @@ pub(crate) fn build_data_column_sidecars<E: EthSpec>( .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, signed_block_header: signed_block_header.clone(), kzg_commitments_inclusion_proof: kzg_commitments_inclusion_proof.clone(), - })) + }))) + }, + ) + .collect(); + + sidecars +} +pub(crate) fn build_data_column_sidecars_gloas<E: EthSpec>( + beacon_block_root: Hash256, + slot: Slot, + blob_cells_and_proofs_vec: Vec<CellsAndKzgProofs>, + spec: &ChainSpec, +) -> Result<DataColumnSidecarList<E>, String> { + if !spec.fork_name_at_slot::<E>(slot).gloas_enabled() { + return Err("Attempting to construct Gloas data columns pre-Gloas".to_owned()); + } + + let number_of_columns = E::number_of_columns(); + let max_blobs_per_block = spec.max_blobs_per_block(slot.epoch(E::slots_per_epoch())) as usize; + let mut columns = vec![Vec::with_capacity(max_blobs_per_block); number_of_columns]; + let mut column_kzg_proofs = vec![Vec::with_capacity(max_blobs_per_block); number_of_columns]; + + for (blob_cells, blob_cell_proofs) in blob_cells_and_proofs_vec { + // we iterate over each column, and we construct the column from "top to bottom", + // pushing on the cell and the corresponding proof at each column index. we do this for + // each blob (i.e. the outer loop). + for col in 0..number_of_columns { + let cell = blob_cells + .get(col) + .ok_or(format!("Missing blob cell at index {col}"))?; + let cell: Vec<u8> = cell.to_vec(); + let cell = + Cell::<E>::try_from(cell).map_err(|e| format!("BytesPerCell exceeded: {e:?}"))?; + + let proof = blob_cell_proofs + .get(col) + .ok_or(format!("Missing blob cell KZG proof at index {col}"))?; + + let column = columns + .get_mut(col) + .ok_or(format!("Missing data column at index {col}"))?; + let column_proofs = column_kzg_proofs + .get_mut(col) + .ok_or(format!("Missing data column proofs at index {col}"))?; + + column.push(cell); + column_proofs.push(*proof); + } + } + + let sidecars: Result<Vec<Arc<DataColumnSidecar<E>>>, String> = columns + .into_iter() + .zip(column_kzg_proofs) + .enumerate() + .map( + |(index, (col, proofs))| -> Result<Arc<DataColumnSidecar<E>>, String> { + Ok(Arc::new(DataColumnSidecar::Gloas(DataColumnSidecarGloas { + index: index as u64, + column: DataColumn::<E>::try_from(col) + .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, + kzg_proofs: VariableList::try_from(proofs) + .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, + beacon_block_root, + slot, + }))) }, ) .collect(); @@ -300,6 +519,89 @@ pub(crate) fn build_data_column_sidecars<E: EthSpec>( sidecars } +pub(crate) fn build_partial_data_columns<E: EthSpec>( + header: &PartialDataColumnHeader<E>, + blob_cells_and_proofs_vec: Vec<Option<CellsAndKzgProofs>>, + spec: &ChainSpec, +) -> Result<Vec<PartialDataColumn<E>>, String> { + let number_of_columns = E::number_of_columns(); + let max_blobs_per_block = + spec.max_blobs_per_block(header.slot().epoch(E::slots_per_epoch())) as usize; + let mut bitmap = + CellBitmap::<E>::with_capacity(blob_cells_and_proofs_vec.len()).map_err(|_| { + format!( + "Exceeded max committment count: {} (got {})", + E::max_blob_commitments_per_block(), + blob_cells_and_proofs_vec.len() + ) + })?; + let mut columns = vec![Vec::with_capacity(max_blobs_per_block); number_of_columns]; + let mut column_kzg_proofs = vec![Vec::with_capacity(max_blobs_per_block); number_of_columns]; + + for (idx, maybe_cells_and_proofs) in blob_cells_and_proofs_vec.into_iter().enumerate() { + let Some((blob_cells, blob_cell_proofs)) = maybe_cells_and_proofs else { + continue; + }; + + bitmap + .set(idx, true) + .expect("bitmap constructed from iterator length above"); + + // we iterate over each column, and we construct the column from "top to bottom", + // pushing on the cell and the corresponding proof at each column index. we do this for + // each blob (i.e. the outer loop). + for col in 0..number_of_columns { + let cell = blob_cells + .get(col) + .ok_or(format!("Missing blob cell at index {col}"))?; + let cell: Vec<u8> = cell.to_vec(); + let cell = + Cell::<E>::try_from(cell).map_err(|e| format!("BytesPerCell exceeded: {e:?}"))?; + + let proof = blob_cell_proofs + .get(col) + .ok_or(format!("Missing blob cell KZG proof at index {col}"))?; + + let column = columns + .get_mut(col) + .ok_or(format!("Missing data column at index {col}"))?; + let column_proofs = column_kzg_proofs + .get_mut(col) + .ok_or(format!("Missing data column proofs at index {col}"))?; + + column.push(cell); + column_proofs.push(*proof); + } + } + + let block_root = header.signed_block_header.message.canonical_root(); + + let sidecars: Result<Vec<PartialDataColumn<E>>, String> = columns + .into_iter() + .zip(column_kzg_proofs) + .enumerate() + .map(|(index, (col, proofs))| { + let column = PartialDataColumn { + block_root, + index: index as u64, + sidecar: types::data::PartialDataColumnSidecar { + cells_present_bitmap: bitmap.clone(), + column: VariableList::try_from(col) + .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, + kzg_proofs: VariableList::try_from(proofs) + .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, + header: None.into(), + }, + }; + Ok(column) + }) + .collect(); + + sidecars +} + +// TODO(gloas) blob reconstruction will fail post gloas. We should just return `Blob`s +// instead of a `BlobSidecar`. This might require a beacon api spec change as well. /// Reconstruct blobs from a subset of data column sidecars (requires at least 50%). /// /// If `blob_indices_opt` is `None`, this function attempts to reconstruct all blobs associated @@ -314,7 +616,7 @@ pub fn reconstruct_blobs<E: EthSpec>( spec: &ChainSpec, ) -> Result<BlobSidecarList<E>, String> { // Sort data columns by index to ensure ascending order for KZG operations - data_columns.sort_unstable_by_key(|dc| dc.index); + data_columns.sort_unstable_by_key(|dc| *dc.index()); let first_data_column = data_columns .first() @@ -323,7 +625,12 @@ pub fn reconstruct_blobs<E: EthSpec>( let blob_indices: Vec<usize> = match blob_indices_opt { Some(indices) => indices.into_iter().map(|i| i as usize).collect(), None => { - let num_of_blobs = first_data_column.kzg_commitments.len(); + // TODO(gloas): support blob reconstruction for Gloas + // https://github.com/sigp/lighthouse/issues/7413 + let num_of_blobs = first_data_column + .kzg_commitments() + .map_err(|_| "Gloas blob reconstruction not yet supported".to_string())? + .len(); (0..num_of_blobs).collect() } }; @@ -335,7 +642,7 @@ pub fn reconstruct_blobs<E: EthSpec>( let mut cell_ids: Vec<u64> = vec![]; for data_column in &data_columns { let cell = data_column - .column + .column() .get(row_index) .ok_or(format!("Missing data column at row index {row_index}")) .and_then(|cell| { @@ -343,7 +650,7 @@ pub fn reconstruct_blobs<E: EthSpec>( })?; cells.push(cell); - cell_ids.push(data_column.index); + cell_ids.push(*data_column.index()); } let num_cells_original_blob = E::number_of_columns() / 2; @@ -370,16 +677,9 @@ pub fn reconstruct_blobs<E: EthSpec>( let blob = Blob::<E>::new(blob_bytes).map_err(|e| format!("{e:?}"))?; let kzg_proof = KzgProof::empty(); - BlobSidecar::<E>::new_with_existing_proof( - row_index, - blob, - signed_block, - first_data_column.signed_block_header.clone(), - &first_data_column.kzg_commitments_inclusion_proof, - kzg_proof, - ) - .map(Arc::new) - .map_err(|e| format!("{e:?}")) + BlobSidecar::<E>::new_with_existing_proof(row_index, blob, signed_block, kzg_proof) + .map(Arc::new) + .map_err(|e| format!("{e:?}")) }) .collect::<Result<Vec<_>, _>>()?; @@ -395,7 +695,7 @@ pub fn reconstruct_data_columns<E: EthSpec>( spec: &ChainSpec, ) -> Result<DataColumnSidecarList<E>, KzgError> { // Sort data columns by index to ensure ascending order for KZG operations - data_columns.sort_unstable_by_key(|dc| dc.index); + data_columns.sort_unstable_by_key(|dc| *dc.index()); let first_data_column = data_columns .first() @@ -403,44 +703,62 @@ pub fn reconstruct_data_columns<E: EthSpec>( "data_columns should have at least one element".to_string(), ))?; - let num_of_blobs = first_data_column.kzg_commitments.len(); + // TODO(gloas): support data column reconstruction for Gloas + // https://github.com/sigp/lighthouse/issues/7413 + let num_of_blobs = first_data_column + .kzg_commitments() + .map_err(|_| { + KzgError::InconsistentArrayLength( + "Gloas data column reconstruction not yet supported".to_string(), + ) + })? + .len(); - let blob_cells_and_proofs_vec = - (0..num_of_blobs) - .into_par_iter() - .map(|row_index| { - let mut cells: Vec<KzgCellRef> = vec![]; - let mut cell_ids: Vec<u64> = vec![]; - for data_column in &data_columns { - let cell = data_column.column.get(row_index).ok_or( - KzgError::InconsistentArrayLength(format!( - "Missing data column at row index {row_index}" - )), - )?; + let blob_cells_and_proofs_vec = (0..num_of_blobs) + .into_par_iter() + .map(|row_index| { + let mut cells: Vec<KzgCellRef> = vec![]; + let mut cell_ids: Vec<u64> = vec![]; + for data_column in &data_columns { + let cell = data_column.column().get(row_index).ok_or( + KzgError::InconsistentArrayLength(format!( + "Missing data column at row index {row_index}" + )), + )?; - cells.push(ssz_cell_to_crypto_cell::<E>(cell)?); - cell_ids.push(data_column.index); - } - kzg.recover_cells_and_compute_kzg_proofs(&cell_ids, &cells) - }) - .collect::<Result<Vec<_>, KzgError>>()?; - - // Clone sidecar elements from existing data column, no need to re-compute - build_data_column_sidecars( - first_data_column.kzg_commitments.clone(), - first_data_column.kzg_commitments_inclusion_proof.clone(), - first_data_column.signed_block_header.clone(), - blob_cells_and_proofs_vec, - spec, - ) - .map_err(KzgError::ReconstructFailed) + cells.push(ssz_cell_to_crypto_cell::<E>(cell)?); + cell_ids.push(*data_column.index()); + } + kzg.recover_cells_and_compute_kzg_proofs(&cell_ids, &cells) + }) + .collect::<Result<Vec<_>, KzgError>>()?; + match first_data_column.as_ref() { + DataColumnSidecar::Fulu(first_column) => { + // Clone sidecar elements from existing data column, no need to re-compute + build_data_column_sidecars_fulu( + first_column.kzg_commitments.clone(), + first_column.kzg_commitments_inclusion_proof.clone(), + first_column.signed_block_header.clone(), + blob_cells_and_proofs_vec, + spec, + ) + .map_err(KzgError::ReconstructFailed) + } + DataColumnSidecar::Gloas(first_column) => build_data_column_sidecars_gloas( + first_column.beacon_block_root, + first_column.slot, + blob_cells_and_proofs_vec, + spec, + ) + .map_err(KzgError::ReconstructFailed), + } } #[cfg(test)] mod test { use crate::kzg_utils::{ - blobs_to_data_column_sidecars, reconstruct_blobs, reconstruct_data_columns, - validate_data_columns, + blobs_to_data_column_sidecars, blobs_to_data_column_sidecars_gloas, reconstruct_blobs, + reconstruct_data_columns, validate_full_data_columns, }; use bls::Signature; use eth2::types::BlobsBundle; @@ -448,8 +766,8 @@ mod test { use kzg::{Kzg, KzgCommitment, trusted_setup::get_trusted_setup}; use types::{ BeaconBlock, BeaconBlockFulu, BlobsList, ChainSpec, EmptyBlock, EthSpec, ForkName, - FullPayload, KzgProofs, MainnetEthSpec, SignedBeaconBlock, - beacon_block_body::KzgCommitments, + FullPayload, Hash256, KzgProofs, MainnetEthSpec, SignedBeaconBlock, Slot, + kzg_ext::KzgCommitments, }; type E = MainnetEthSpec; @@ -458,15 +776,20 @@ mod test { // only load it once. #[test] fn test_build_data_columns_sidecars() { - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); let kzg = get_kzg(); - test_build_data_columns_empty(&kzg, &spec); - test_build_data_columns(&kzg, &spec); - test_reconstruct_data_columns(&kzg, &spec); - test_reconstruct_data_columns_unordered(&kzg, &spec); - test_reconstruct_blobs_from_data_columns(&kzg, &spec); - test_reconstruct_blobs_from_data_columns_unordered(&kzg, &spec); - test_validate_data_columns(&kzg, &spec); + + let fulu_spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + test_build_data_columns_empty(&kzg, &fulu_spec); + test_build_data_columns_fulu(&kzg, &fulu_spec); + test_reconstruct_data_columns(&kzg, &fulu_spec); + test_reconstruct_data_columns_unordered(&kzg, &fulu_spec); + test_reconstruct_blobs_from_data_columns(&kzg, &fulu_spec); + test_reconstruct_blobs_from_data_columns_unordered(&kzg, &fulu_spec); + test_validate_data_columns(&kzg, &fulu_spec); + + let gloas_spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); + test_build_data_columns_gloas(&kzg, &gloas_spec); + test_build_data_columns_gloas_empty(&kzg, &gloas_spec); } #[track_caller] @@ -479,7 +802,7 @@ mod test { blobs_to_data_column_sidecars(&blob_refs, proofs.to_vec(), &signed_block, kzg, spec) .unwrap(); - let result = validate_data_columns::<E, _>(kzg, column_sidecars.iter()); + let result = validate_full_data_columns(kzg, column_sidecars.iter()); assert!(result.is_ok()); } @@ -496,7 +819,50 @@ mod test { } #[track_caller] - fn test_build_data_columns(kzg: &Kzg, spec: &ChainSpec) { + fn test_build_data_columns_gloas(kzg: &Kzg, spec: &ChainSpec) { + let num_of_blobs = 2; + let (blobs, _proofs) = create_test_gloas_blobs::<E>(num_of_blobs); + let beacon_block_root = Hash256::random(); + let slot = Slot::new(0); + + let blob_refs: Vec<_> = blobs.iter().collect(); + let column_sidecars = blobs_to_data_column_sidecars_gloas::<E>( + &blob_refs, + beacon_block_root, + slot, + kzg, + spec, + ) + .unwrap(); + + assert_eq!(column_sidecars.len(), E::number_of_columns()); + for (idx, col_sidecar) in column_sidecars.iter().enumerate() { + assert_eq!(*col_sidecar.index(), idx as u64); + assert_eq!(col_sidecar.column().len(), num_of_blobs); + assert_eq!(col_sidecar.kzg_proofs().len(), num_of_blobs); + + let gloas_col = col_sidecar.as_gloas().expect("should be Gloas sidecar"); + assert_eq!(gloas_col.beacon_block_root, beacon_block_root); + assert_eq!(gloas_col.slot, slot); + } + } + + #[track_caller] + fn test_build_data_columns_gloas_empty(kzg: &Kzg, spec: &ChainSpec) { + let blob_refs: Vec<&types::Blob<E>> = vec![]; + let column_sidecars = blobs_to_data_column_sidecars_gloas::<E>( + &blob_refs, + Hash256::random(), + Slot::new(0), + kzg, + spec, + ) + .unwrap(); + assert!(column_sidecars.is_empty()); + } + + #[track_caller] + fn test_build_data_columns_fulu(kzg: &Kzg, spec: &ChainSpec) { // Using at least 2 blobs to make sure we're arranging the data columns correctly. let num_of_blobs = 2; let (signed_block, blobs, proofs) = @@ -521,18 +887,24 @@ mod test { assert_eq!(column_sidecars.len(), E::number_of_columns()); for (idx, col_sidecar) in column_sidecars.iter().enumerate() { - assert_eq!(col_sidecar.index, idx as u64); + assert_eq!(*col_sidecar.index(), idx as u64); - assert_eq!(col_sidecar.kzg_commitments.len(), num_of_blobs); - assert_eq!(col_sidecar.column.len(), num_of_blobs); - assert_eq!(col_sidecar.kzg_proofs.len(), num_of_blobs); + assert_eq!(col_sidecar.kzg_commitments().unwrap().len(), num_of_blobs); + assert_eq!(col_sidecar.column().len(), num_of_blobs); + assert_eq!(col_sidecar.kzg_proofs().len(), num_of_blobs); - assert_eq!(col_sidecar.kzg_commitments, block_kzg_commitments); assert_eq!( - col_sidecar.kzg_commitments_inclusion_proof, + col_sidecar.kzg_commitments().unwrap().clone(), + block_kzg_commitments + ); + assert_eq!( + col_sidecar + .kzg_commitments_inclusion_proof() + .unwrap() + .clone(), block_kzg_commitments_inclusion_proof ); - assert!(col_sidecar.verify_inclusion_proof()); + assert!(col_sidecar.as_fulu().unwrap().verify_inclusion_proof()); } } @@ -677,4 +1049,9 @@ mod test { (signed_block, blobs, proofs) } + + fn create_test_gloas_blobs<E: EthSpec>(num_of_blobs: usize) -> (BlobsList<E>, KzgProofs<E>) { + let (blobs_bundle, _) = generate_blobs::<E>(num_of_blobs, ForkName::Gloas).unwrap(); + (blobs_bundle.blobs, blobs_bundle.proofs) + } } diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 4582d5f198..33c434cf6c 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -1,7 +1,6 @@ pub mod attestation_rewards; pub mod attestation_simulator; pub mod attestation_verification; -mod attester_cache; pub mod beacon_block_reward; mod beacon_block_streamer; mod beacon_chain; @@ -10,7 +9,7 @@ pub mod beacon_proposer_cache; mod beacon_snapshot; pub mod bellatrix_readiness; pub mod blob_verification; -pub mod block_reward; +mod block_production; mod block_times_cache; mod block_verification; pub mod block_verification_types; @@ -21,16 +20,17 @@ pub mod custody_context; pub mod data_availability_checker; pub mod data_column_verification; mod early_attester_cache; +pub mod envelope_times_cache; mod errors; pub mod events; pub mod execution_payload; pub mod fetch_blobs; pub mod fork_choice_signal; -pub mod fork_revert; pub mod graffiti_calculator; pub mod historical_blocks; pub mod historical_data_columns; pub mod inclusion_list_verification; +pub mod invariants; pub mod kzg_utils; pub mod light_client_finality_update_verification; pub mod light_client_optimistic_update_verification; @@ -44,10 +44,17 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod partial_data_column_assembler; +pub mod payload_attestation_verification; +pub mod payload_bid_verification; +pub mod payload_envelope_streamer; +pub mod payload_envelope_verification; +pub mod pending_payload_envelopes; pub mod persisted_beacon_chain; pub mod persisted_custody; mod persisted_fork_choice; mod pre_finalization_cache; +pub mod proposer_preferences_verification; pub mod proposer_prep_service; pub mod schema_change; pub mod shuffling_cache; @@ -73,14 +80,14 @@ pub use self::errors::{BeaconChainError, BlockProductionError}; pub use self::historical_blocks::HistoricalBlockError; pub use attestation_verification::Error as AttestationError; pub use beacon_fork_choice_store::{ - BeaconForkChoiceStore, Error as ForkChoiceStoreError, PersistedForkChoiceStoreV17, + BeaconForkChoiceStore, Error as ForkChoiceStoreError, PersistedForkChoiceStore, PersistedForkChoiceStoreV28, }; pub use block_verification::{ BlockError, ExecutionPayloadError, ExecutionPendingBlock, GossipVerifiedBlock, IntoExecutionPendingBlock, IntoGossipVerifiedBlock, InvalidSignature, PayloadVerificationOutcome, PayloadVerificationStatus, build_blob_data_column_sidecars, - get_block_root, + get_block_root, signature_verify_chain_segment, }; pub use block_verification_types::AvailabilityPendingExecutedBlock; pub use block_verification_types::ExecutedBlock; diff --git a/beacon_node/beacon_chain/src/light_client_finality_update_verification.rs b/beacon_node/beacon_chain/src/light_client_finality_update_verification.rs index 2dc4de7d04..81bc03a402 100644 --- a/beacon_node/beacon_chain/src/light_client_finality_update_verification.rs +++ b/beacon_node/beacon_chain/src/light_client_finality_update_verification.rs @@ -75,9 +75,9 @@ impl<T: BeaconChainTypes> VerifiedLightClientFinalityUpdate<T> { .slot_clock .start_of(rcv_finality_update.signature_slot()) .ok_or(Error::SigSlotStartIsNone)?; - let one_third_slot_duration = Duration::new(chain.spec.seconds_per_slot / 3, 0); + let sync_message_due = chain.spec.get_sync_message_due(); if seen_timestamp + chain.spec.maximum_gossip_clock_disparity() - < start_time + one_third_slot_duration + < start_time + sync_message_due { return Err(Error::TooEarly); } diff --git a/beacon_node/beacon_chain/src/light_client_optimistic_update_verification.rs b/beacon_node/beacon_chain/src/light_client_optimistic_update_verification.rs index 4079a374f8..826f170b80 100644 --- a/beacon_node/beacon_chain/src/light_client_optimistic_update_verification.rs +++ b/beacon_node/beacon_chain/src/light_client_optimistic_update_verification.rs @@ -70,9 +70,11 @@ impl<T: BeaconChainTypes> VerifiedLightClientOptimisticUpdate<T> { .slot_clock .start_of(rcv_optimistic_update.signature_slot()) .ok_or(Error::SigSlotStartIsNone)?; - let one_third_slot_duration = Duration::new(chain.spec.seconds_per_slot / 3, 0); + + let sync_message_due = chain.spec.get_sync_message_due(); + if seen_timestamp + chain.spec.maximum_gossip_clock_disparity() - < start_time + one_third_slot_duration + < start_time + sync_message_due { return Err(Error::TooEarly); } diff --git a/beacon_node/beacon_chain/src/light_client_server_cache.rs b/beacon_node/beacon_chain/src/light_client_server_cache.rs index 487ddfd3ec..5b405234e7 100644 --- a/beacon_node/beacon_chain/src/light_client_server_cache.rs +++ b/beacon_node/beacon_chain/src/light_client_server_cache.rs @@ -9,7 +9,7 @@ use store::DBColumn; use store::KeyValueStore; use tracing::debug; use tree_hash::TreeHash; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; use types::{ BeaconBlockRef, BeaconState, ChainSpec, Checkpoint, EthSpec, ForkName, Hash256, LightClientBootstrap, LightClientFinalityUpdate, LightClientOptimisticUpdate, diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index 422c85364e..9989d285fa 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -21,6 +21,34 @@ pub const VALIDATOR_MONITOR_ATTESTATION_SIMULATOR_SOURCE_ATTESTER_HIT_TOTAL: &st pub const VALIDATOR_MONITOR_ATTESTATION_SIMULATOR_SOURCE_ATTESTER_MISS_TOTAL: &str = "validator_monitor_attestation_simulator_source_attester_miss_total"; +/* +* Execution Payload Envelope Processing +*/ + +pub static ENVELOPE_PROCESSING_REQUESTS: LazyLock<Result<IntCounter>> = LazyLock::new(|| { + try_create_int_counter( + "payload_envelope_processing_requests_total", + "Count of payload envelopes submitted for processing", + ) +}); +pub static ENVELOPE_PROCESSING_SUCCESSES: LazyLock<Result<IntCounter>> = LazyLock::new(|| { + try_create_int_counter( + "payload_envelope_processing_successes_total", + "Count of payload envelopes processed without error", + ) +}); +pub static ENVELOPE_PROCESSING_TIMES: LazyLock<Result<Histogram>> = LazyLock::new(|| { + try_create_histogram( + "payload_envelope_processing_seconds", + "Full runtime of payload envelope processing", + ) +}); +pub static ENVELOPE_PROCESSING_DB_WRITE: LazyLock<Result<Histogram>> = LazyLock::new(|| { + try_create_histogram( + "payload_envelope_processing_db_write_seconds", + "Time spent writing a newly processed payload envelope and state to DB", + ) +}); /* * Block Processing */ @@ -482,18 +510,15 @@ pub static ATTESTATION_PRODUCTION_HEAD_SCRAPE_SECONDS: LazyLock<Result<Histogram "Time taken to read the head state", ) }); -pub static ATTESTATION_PRODUCTION_CACHE_INTERACTION_SECONDS: LazyLock<Result<Histogram>> = + +/* + * Payload Attestation Production + */ +pub static PAYLOAD_ATTESTATION_PRODUCTION_SECONDS: LazyLock<Result<Histogram>> = LazyLock::new(|| { try_create_histogram( - "attestation_production_cache_interaction_seconds", - "Time spent interacting with the attester cache", - ) - }); -pub static ATTESTATION_PRODUCTION_CACHE_PRIME_SECONDS: LazyLock<Result<Histogram>> = - LazyLock::new(|| { - try_create_histogram( - "attestation_production_cache_prime_seconds", - "Time spent loading a new state from the disk due to a cache miss", + "beacon_payload_attestation_production_seconds", + "Full runtime of payload attestation production", ) }); @@ -1467,6 +1492,27 @@ pub static SYNC_MESSAGE_GOSSIP_VERIFICATION_TIMES: LazyLock<Result<Histogram>> = "Full runtime of sync contribution gossip verification", ) }); +pub static PAYLOAD_ATTESTATION_PROCESSING_REQUESTS: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_payload_attestation_processing_requests_total", + "Count of all payload attestation messages submitted for processing", + ) + }); +pub static PAYLOAD_ATTESTATION_PROCESSING_SUCCESSES: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_payload_attestation_processing_successes_total", + "Number of payload attestation messages verified for gossip", + ) + }); +pub static PAYLOAD_ATTESTATION_GOSSIP_VERIFICATION_TIMES: LazyLock<Result<Histogram>> = + LazyLock::new(|| { + try_create_histogram( + "beacon_payload_attestation_gossip_verification_seconds", + "Full runtime of payload attestation gossip verification", + ) + }); pub static SYNC_MESSAGE_EQUIVOCATIONS: LazyLock<Result<IntCounter>> = LazyLock::new(|| { try_create_int_counter( "sync_message_equivocations_total", @@ -1651,10 +1697,9 @@ pub static BLOB_SIDECAR_INCLUSION_PROOF_COMPUTATION: LazyLock<Result<Histogram>> ) }); pub static DATA_COLUMN_SIDECAR_COMPUTATION: LazyLock<Result<HistogramVec>> = LazyLock::new(|| { - try_create_histogram_vec_with_buckets( + try_create_histogram_vec( "beacon_data_column_sidecar_computation_seconds", "Time taken to compute data column sidecar, including cells, proofs and inclusion proof", - Ok(vec![0.1, 0.15, 0.25, 0.35, 0.5, 0.7, 1.0, 2.5, 5.0, 10.0]), &["blob_count"], ) }); @@ -1686,6 +1731,56 @@ pub static DATA_COLUMN_SIDECAR_GOSSIP_VERIFICATION_TIMES: LazyLock<Result<Histog "Full runtime of data column sidecars gossip verification", ) }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_REQUESTS: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_header_processing_requests_total", + "Count of all partial data column sidecars submitted for processing", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_DUPES: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_header_processing_dupes_total", + "Number of partial data column sidecars verified for gossip (excluding dupes)", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_SUCCESSES: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_header_processing_successes_total", + "Number of partial data column sidecar headers verified for gossip (excluding dupes)", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_GOSSIP_VERIFICATION_TIMES: LazyLock< + Result<Histogram>, +> = LazyLock::new(|| { + try_create_histogram( + "beacon_partial_data_column_sidecar_header_gossip_verification_seconds", + "Full runtime of partial data column sidecar headers gossip verification", + ) +}); +pub static PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_REQUESTS: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_processing_requests_total", + "Count of all partial data column sidecars submitted for processing", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_SUCCESSES: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_processing_successes_total", + "Number of partial data column sidecars verified for gossip", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_GOSSIP_VERIFICATION_TIMES: LazyLock<Result<Histogram>> = + LazyLock::new(|| { + try_create_histogram( + "beacon_partial_data_column_sidecar_gossip_verification_seconds", + "Full runtime of partial data column sidecars gossip verification", + ) + }); pub static BLOBS_FROM_EL_HIT_TOTAL: LazyLock<Result<IntCounter>> = LazyLock::new(|| { try_create_int_counter( @@ -1728,6 +1823,97 @@ pub static BLOBS_FROM_EL_RECEIVED: LazyLock<Result<Histogram>> = LazyLock::new(| ) }); +/* + * Standardized getBlobs metrics across clients from https://github.com/ethereum/beacon-metrics + */ +pub static BEACON_ENGINE_GET_BLOBS_V2_REQUESTS_TOTAL: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV2_requests_total", + "Total number of engine_getBlobsV2 requests made to the execution layer", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V2_RESPONSES_TOTAL: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV2_responses_total", + "Total number of successful engine_getBlobsV2 responses from the execution layer", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V2_REQUEST_DURATION_SECONDS: LazyLock<Result<Histogram>> = + LazyLock::new(|| { + try_create_histogram( + "beacon_engine_getBlobsV2_request_duration_seconds", + "Duration of engine_getBlobsV2 requests to the execution layer in seconds", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_REQUESTS_TOTAL: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV3_requests_total", + "Total number of engine_getBlobsV3 requests made to the execution layer", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_COMPLETE_RESPONSES_TOTAL: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV3_complete_responses_total", + "Total number of successful engine_getBlobsV3 responses from the execution layer \ + with all blobs", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_PARTIAL_RESPONSES_TOTAL: LazyLock<Result<IntCounter>> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV3_partial_responses_total", + "Total number of successful engine_getBlobsV3 responses from the execution layer \ + with at least one blob missing", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_REQUEST_DURATION_SECONDS: LazyLock<Result<Histogram>> = + LazyLock::new(|| { + try_create_histogram( + "beacon_engine_getBlobsV3_request_duration_seconds", + "Duration of engine_getBlobsV3 requests to the execution layer in seconds", + ) + }); + +/* + * Standardized metrics for partial column efficiency + */ +pub static BEACON_PARTIAL_MESSAGE_USEFUL_CELLS_TOTAL: LazyLock<Result<IntCounterVec>> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_partial_message_useful_cells_total", + "Number of useful cells received via a partial message", + &["column_index"], + ) + }); + +pub static BEACON_PARTIAL_MESSAGE_CELLS_RECEIVED_TOTAL: LazyLock<Result<IntCounterVec>> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_partial_message_cells_received_total", + "Number of total cells received via a partial message", + &["column_index"], + ) + }); + +pub static BEACON_PARTIAL_MESSAGE_COLUMN_COMPLETIONS_TOTAL: LazyLock<Result<IntCounterVec>> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_partial_message_column_completions_total", + "How often the partial message first completed the column", + &["column_index"], + ) + }); + /* * Light server message verification */ @@ -1881,13 +2067,6 @@ pub static DATA_AVAILABILITY_OVERFLOW_MEMORY_BLOCK_CACHE_SIZE: LazyLock<Result<I "Number of entries in the data availability overflow block memory cache.", ) }); -pub static DATA_AVAILABILITY_OVERFLOW_MEMORY_STATE_CACHE_SIZE: LazyLock<Result<IntGauge>> = - LazyLock::new(|| { - try_create_int_gauge( - "data_availability_overflow_memory_state_cache_size", - "Number of entries in the data availability overflow state memory cache.", - ) - }); pub static DATA_AVAILABILITY_RECONSTRUCTION_TIME: LazyLock<Result<Histogram>> = LazyLock::new(|| { try_create_histogram( @@ -1995,10 +2174,6 @@ pub fn scrape_for_metrics<T: BeaconChainTypes>(beacon_chain: &BeaconChain<T>) { &DATA_AVAILABILITY_OVERFLOW_MEMORY_BLOCK_CACHE_SIZE, da_checker_metrics.block_cache_size, ); - set_gauge_by_usize( - &DATA_AVAILABILITY_OVERFLOW_MEMORY_STATE_CACHE_SIZE, - da_checker_metrics.state_cache_size, - ); if let Some((size, num_lookups)) = beacon_chain.pre_finalization_block_cache.metrics() { set_gauge_by_usize(&PRE_FINALIZATION_BLOCK_CACHE_SIZE, size); diff --git a/beacon_node/beacon_chain/src/migrate.rs b/beacon_node/beacon_chain/src/migrate.rs index bd232f2e8a..3c17c1ebba 100644 --- a/beacon_node/beacon_chain/src/migrate.rs +++ b/beacon_node/beacon_chain/src/migrate.rs @@ -330,7 +330,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> BackgroundMigrator<E, Ho let finalized_block_root = notif.finalized_checkpoint.root; // The enshrined finalized state should be in the state cache. - let finalized_state = match db.get_state(&finalized_state_root.into(), None, true) { + let finalized_state = match db.get_hot_state(&finalized_state_root.into(), true) { Ok(Some(state)) => state, other => { error!( @@ -773,6 +773,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> BackgroundMigrator<E, Ho StoreOp::DeleteBlock(block_root), StoreOp::DeleteExecutionPayload(block_root), StoreOp::DeleteBlobs(block_root), + StoreOp::DeletePayloadEnvelope(block_root), StoreOp::DeleteSyncCommitteeBranch(block_root), ] }) diff --git a/beacon_node/beacon_chain/src/naive_aggregation_pool.rs b/beacon_node/beacon_chain/src/naive_aggregation_pool.rs index beefc2d678..72080b92da 100644 --- a/beacon_node/beacon_chain/src/naive_aggregation_pool.rs +++ b/beacon_node/beacon_chain/src/naive_aggregation_pool.rs @@ -4,9 +4,9 @@ use itertools::Itertools; use smallvec::SmallVec; use std::collections::HashMap; use tree_hash::{MerkleHasher, TreeHash, TreeHashType}; +use types::SlotData; use types::consts::altair::SYNC_COMMITTEE_SUBNET_COUNT; -use types::slot_data::SlotData; -use types::sync_committee_contribution::SyncContributionData; +use types::sync_committee::SyncContributionData; use types::{ Attestation, AttestationData, AttestationRef, CommitteeIndex, EthSpec, Hash256, Slot, SyncCommitteeContribution, diff --git a/beacon_node/beacon_chain/src/observed_aggregates.rs b/beacon_node/beacon_chain/src/observed_aggregates.rs index b2c5cb4b38..7ecd581e85 100644 --- a/beacon_node/beacon_chain/src/observed_aggregates.rs +++ b/beacon_node/beacon_chain/src/observed_aggregates.rs @@ -7,10 +7,10 @@ use std::collections::HashMap; use std::marker::PhantomData; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; +use types::SlotData; use types::consts::altair::{ SYNC_COMMITTEE_SUBNET_COUNT, TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE, }; -use types::slot_data::SlotData; use types::{ Attestation, AttestationData, AttestationRef, EthSpec, Hash256, Slot, SyncCommitteeContribution, }; diff --git a/beacon_node/beacon_chain/src/observed_attesters.rs b/beacon_node/beacon_chain/src/observed_attesters.rs index d5433f49d1..4bb536880c 100644 --- a/beacon_node/beacon_chain/src/observed_attesters.rs +++ b/beacon_node/beacon_chain/src/observed_attesters.rs @@ -20,7 +20,7 @@ use std::collections::{HashMap, HashSet}; use std::hash::Hash; use std::marker::PhantomData; use typenum::Unsigned; -use types::slot_data::SlotData; +use types::SlotData; use types::{Epoch, EthSpec, Hash256, Slot}; /// The maximum capacity of the `AutoPruningEpochContainer`. @@ -42,6 +42,8 @@ pub type ObservedSyncContributors<E> = pub type ObservedAggregators<E> = AutoPruningEpochContainer<EpochHashSet, E>; pub type ObservedSyncAggregators<E> = AutoPruningSlotContainer<SlotSubcommitteeIndex, (), SyncAggregatorSlotHashSet, E>; +pub type ObservedPayloadAttesters<E> = + AutoPruningSlotContainer<Slot, (), PayloadAttesterSlotHashSet<E>, E>; #[derive(Debug, PartialEq)] pub enum Error { @@ -255,6 +257,46 @@ impl Item<()> for SyncAggregatorSlotHashSet { } } +/// Stores a `HashSet` of validator indices that have sent a payload attestation gossip +/// message during a slot. +pub struct PayloadAttesterSlotHashSet<E> { + set: HashSet<usize>, + phantom: PhantomData<E>, +} + +impl<E: EthSpec> Item<()> for PayloadAttesterSlotHashSet<E> { + fn with_capacity(capacity: usize) -> Self { + Self { + set: HashSet::with_capacity(capacity), + phantom: PhantomData, + } + } + + /// Defaults to `PTC_SIZE`, the maximum number of payload attesters per slot. + fn default_capacity() -> usize { + E::ptc_size() + } + + fn len(&self) -> usize { + self.set.len() + } + + fn validator_count(&self) -> usize { + self.set.len() + } + + /// Inserts the `validator_index` in the set. Returns `true` if the `validator_index` was + /// already in the set. + fn insert(&mut self, validator_index: usize, _value: ()) -> bool { + !self.set.insert(validator_index) + } + + /// Returns `true` if the `validator_index` is in the set. + fn get(&self, validator_index: usize) -> Option<()> { + self.set.contains(&validator_index).then_some(()) + } +} + /// A container that stores some number of `T` items. /// /// This container is "auto-pruning" since it gets an idea of the current slot by which diff --git a/beacon_node/beacon_chain/src/observed_data_sidecars.rs b/beacon_node/beacon_chain/src/observed_data_sidecars.rs index 46a3678f16..2461c8115d 100644 --- a/beacon_node/beacon_chain/src/observed_data_sidecars.rs +++ b/beacon_node/beacon_chain/src/observed_data_sidecars.rs @@ -3,27 +3,38 @@ //! Only `BlobSidecar`s that have completed proposer signature verification can be added //! to this cache to reduce DoS risks. -use crate::observed_block_producers::ProposalKey; use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::sync::Arc; -use types::{BlobSidecar, ChainSpec, DataColumnSidecar, EthSpec, Slot}; +use types::{ + BlobSidecar, ChainSpec, DataColumnSidecar, EthSpec, Hash256, PartialDataColumnHeader, Slot, +}; + +type ValidatorIndex = u64; +type BeaconBlockRoot = Hash256; #[derive(Debug, PartialEq)] pub enum Error { /// The slot of the provided `ObservableDataSidecar` is prior to finalization and should not have been provided /// to this function. This is an internal error. - FinalizedDataSidecar { slot: Slot, finalized_slot: Slot }, + FinalizedDataSidecar { + slot: Slot, + finalized_slot: Slot, + }, /// The data sidecar contains an invalid index, the data sidecar is invalid. /// Note: The invalid data should have been caught and flagged as an error much before reaching /// here. InvalidDataIndex(u64), + + // An unexpected data sidecar variant was received + UnexpectedVariant, } pub trait ObservableDataSidecar { fn slot(&self) -> Slot; - fn block_proposer_index(&self) -> u64; fn index(&self) -> u64; + fn proposer_index(&self) -> Option<ValidatorIndex>; + fn beacon_block_root(&self) -> BeaconBlockRoot; fn max_num_of_items(spec: &ChainSpec, slot: Slot) -> usize; } @@ -32,14 +43,18 @@ impl<E: EthSpec> ObservableDataSidecar for BlobSidecar<E> { self.slot() } - fn block_proposer_index(&self) -> u64 { - self.block_proposer_index() - } - fn index(&self) -> u64 { self.index } + fn proposer_index(&self) -> Option<ValidatorIndex> { + Some(self.block_proposer_index()) + } + + fn beacon_block_root(&self) -> BeaconBlockRoot { + self.block_root() + } + fn max_num_of_items(spec: &ChainSpec, slot: Slot) -> usize { spec.max_blobs_per_block(slot.epoch(E::slots_per_epoch())) as usize } @@ -50,12 +65,16 @@ impl<E: EthSpec> ObservableDataSidecar for DataColumnSidecar<E> { self.slot() } - fn block_proposer_index(&self) -> u64 { - self.block_proposer_index() + fn index(&self) -> u64 { + *self.index() } - fn index(&self) -> u64 { - self.index + fn proposer_index(&self) -> Option<ValidatorIndex> { + self.as_fulu().map(|d| d.block_proposer_index()).ok() + } + + fn beacon_block_root(&self) -> BeaconBlockRoot { + self.block_root() } fn max_num_of_items(_spec: &ChainSpec, _slot: Slot) -> usize { @@ -63,6 +82,58 @@ impl<E: EthSpec> ObservableDataSidecar for DataColumnSidecar<E> { } } +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum ObservationKey { + ProposerKey((ValidatorIndex, Slot)), + BlockRootKey((BeaconBlockRoot, Slot)), +} + +impl ObservationKey { + pub fn new<T: ObservableDataSidecar, E: EthSpec>( + sidecar: &T, + spec: &ChainSpec, + ) -> Result<Self, Error> { + let slot = sidecar.slot(); + + if spec.fork_name_at_slot::<E>(slot).gloas_enabled() { + Ok(Self::new_block_root_key(sidecar.beacon_block_root(), slot)) + } else if let Some(proposer_index) = sidecar.proposer_index() { + Ok(Self::new_proposer_key(proposer_index, slot)) + } else { + Err(Error::UnexpectedVariant) + } + } + + pub fn from_partial_column_header<E: EthSpec>( + header: &PartialDataColumnHeader<E>, + block_root: Hash256, + spec: &ChainSpec, + ) -> Self { + let slot = header.slot(); + + if spec.fork_name_at_slot::<E>(slot).gloas_enabled() { + Self::new_block_root_key(block_root, slot) + } else { + Self::new_proposer_key(header.signed_block_header.message.proposer_index, slot) + } + } + + pub fn new_proposer_key(proposer_index: ValidatorIndex, slot: Slot) -> Self { + Self::ProposerKey((proposer_index, slot)) + } + + pub fn new_block_root_key(beacon_block_root: BeaconBlockRoot, slot: Slot) -> Self { + Self::BlockRootKey((beacon_block_root, slot)) + } + + pub fn slot(&self) -> Slot { + match self { + ObservationKey::ProposerKey((_, slot)) => *slot, + ObservationKey::BlockRootKey((_, slot)) => *slot, + } + } +} + /// Maintains a cache of seen `ObservableDataSidecar`s that are received over gossip /// and have been gossip verified. /// @@ -71,15 +142,15 @@ impl<E: EthSpec> ObservableDataSidecar for DataColumnSidecar<E> { /// /// Note: To prevent DoS attacks, this cache must include only items that have received some DoS resistance /// like checking the proposer signature. -pub struct ObservedDataSidecars<T: ObservableDataSidecar> { +pub struct ObservedDataSidecars<T: ObservableDataSidecar, E: EthSpec> { finalized_slot: Slot, - /// Stores all received data indices for a given `(ValidatorIndex, Slot)` tuple. - items: HashMap<ProposalKey, HashSet<u64>>, + /// Stores all received data indices for a given `ObservationKey`. + items: HashMap<ObservationKey, HashSet<u64>>, spec: Arc<ChainSpec>, - _phantom: PhantomData<T>, + _phantom: PhantomData<(T, E)>, } -impl<T: ObservableDataSidecar> ObservedDataSidecars<T> { +impl<T: ObservableDataSidecar, E: EthSpec> ObservedDataSidecars<T, E> { /// Instantiates `Self` with `finalized_slot == 0`. pub fn new(spec: Arc<ChainSpec>) -> Self { Self { @@ -90,42 +161,48 @@ impl<T: ObservableDataSidecar> ObservedDataSidecars<T> { } } - /// Observe the `data_sidecar` at (`data_sidecar.block_proposer_index, data_sidecar.slot`). - /// This will update `self` so future calls to it indicate that this `data_sidecar` is known. + /// Observe the `data_sidecar` at `ObservationKey`. + /// Observes the sidecar, returning `Some(key)` if it was already known, `None` if newly added. /// - /// The supplied `data_sidecar` **MUST** have completed proposer signature verification. - pub fn observe_sidecar(&mut self, data_sidecar: &T) -> Result<bool, Error> { + /// This will update `self` so future calls indicate that this `data_sidecar` is known. + pub fn observe_sidecar(&mut self, data_sidecar: &T) -> Result<Option<ObservationKey>, Error> { self.sanitize_data_sidecar(data_sidecar)?; + let observation_key = ObservationKey::new::<T, E>(data_sidecar, &self.spec)?; + let data_indices = self .items - .entry(ProposalKey { - slot: data_sidecar.slot(), - proposer: data_sidecar.block_proposer_index(), - }) + .entry(observation_key.clone()) .or_insert_with(|| { HashSet::with_capacity(T::max_num_of_items(&self.spec, data_sidecar.slot())) }); let did_not_exist = data_indices.insert(data_sidecar.index()); - Ok(!did_not_exist) + Ok((!did_not_exist).then_some(observation_key)) } - /// Returns `true` if the `data_sidecar` has already been observed in the cache within the prune window. - pub fn proposer_is_known(&self, data_sidecar: &T) -> Result<bool, Error> { + /// Returns `Some(key)` if the sidecar has already been observed, `None` otherwise. + pub fn observation_key_is_known( + &self, + data_sidecar: &T, + ) -> Result<Option<ObservationKey>, Error> { self.sanitize_data_sidecar(data_sidecar)?; + + let observation_key = ObservationKey::new::<T, E>(data_sidecar, &self.spec)?; + let is_known = self .items - .get(&ProposalKey { - slot: data_sidecar.slot(), - proposer: data_sidecar.block_proposer_index(), - }) + .get(&observation_key) .is_some_and(|indices| indices.contains(&data_sidecar.index())); - Ok(is_known) + + Ok(is_known.then_some(observation_key)) } - pub fn known_for_proposal(&self, proposal_key: &ProposalKey) -> Option<&HashSet<u64>> { - self.items.get(proposal_key) + pub fn known_for_observation_key( + &self, + observation_key: &ObservationKey, + ) -> Option<&HashSet<u64>> { + self.items.get(observation_key) } fn sanitize_data_sidecar(&self, data_sidecar: &T) -> Result<(), Error> { @@ -150,7 +227,7 @@ impl<T: ObservableDataSidecar> ObservedDataSidecars<T> { } self.finalized_slot = finalized_slot; - self.items.retain(|k, _| k.slot > finalized_slot); + self.items.retain(|k, _| k.slot() > finalized_slot); } } @@ -182,38 +259,100 @@ impl ObservationStrategy for DoNotObserve { #[cfg(test)] mod tests { - use super::*; use crate::test_utils::test_spec; - use bls::Hash256; + + use super::*; + use bls::{FixedBytesExtended, Signature}; use std::sync::Arc; - use types::{Epoch, MainnetEthSpec}; + use types::{ + BeaconBlockHeader, DataColumnSidecarFulu, DataColumnSidecarGloas, ForkName, MainnetEthSpec, + SignedBeaconBlockHeader, + }; type E = MainnetEthSpec; - fn get_blob_sidecar(slot: u64, proposer_index: u64, index: u64) -> Arc<BlobSidecar<E>> { - let mut blob_sidecar = BlobSidecar::empty(); - blob_sidecar.signed_block_header.message.slot = slot.into(); - blob_sidecar.signed_block_header.message.proposer_index = proposer_index; - blob_sidecar.index = index; - Arc::new(blob_sidecar) + /// Creates a Fulu DataColumnSidecar for testing. + /// Keyed by (proposer_index, slot) in the observation cache. + fn get_data_column_sidecar_fulu( + slot: u64, + proposer_index: u64, + index: u64, + ) -> Arc<DataColumnSidecar<E>> { + let signed_block_header = SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: slot.into(), + proposer_index, + parent_root: Hash256::ZERO, + state_root: Hash256::ZERO, + // Use proposer_index as a simple way to generate different block roots + body_root: Hash256::from_low_u64_be(proposer_index), + }, + signature: Signature::empty(), + }; + Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index, + column: vec![].try_into().unwrap(), + kzg_commitments: vec![].try_into().unwrap(), + kzg_proofs: vec![].try_into().unwrap(), + signed_block_header, + kzg_commitments_inclusion_proof: vec![ + Hash256::ZERO; + E::kzg_commitments_inclusion_proof_depth() + ] + .try_into() + .unwrap(), + })) + } + + /// Creates a Gloas DataColumnSidecar for testing. + /// Keyed by (beacon_block_root, slot) in the observation cache. + fn get_data_column_sidecar_gloas( + slot: u64, + beacon_block_root: Hash256, + index: u64, + ) -> Arc<DataColumnSidecar<E>> { + Arc::new(DataColumnSidecar::Gloas(DataColumnSidecarGloas { + index, + column: vec![].try_into().unwrap(), + kzg_proofs: vec![].try_into().unwrap(), + slot: slot.into(), + beacon_block_root, + })) + } + + fn get_sidecar( + slot: u64, + key: u64, + index: u64, + fork_name: ForkName, + ) -> Arc<DataColumnSidecar<E>> { + if fork_name.gloas_enabled() { + get_data_column_sidecar_gloas(slot, Hash256::from_low_u64_be(key), index) + } else { + get_data_column_sidecar_fulu(slot, key, index) + } } #[test] fn pruning() { let spec = Arc::new(test_spec::<E>()); - let mut cache = ObservedDataSidecars::<BlobSidecar<E>>::new(spec); + let fork_name = spec.fork_name_at_slot::<E>(Slot::new(0)); + + let mut cache = ObservedDataSidecars::<DataColumnSidecar<E>, E>::new(spec.clone()); assert_eq!(cache.finalized_slot, 0, "finalized slot is zero"); assert_eq!(cache.items.len(), 0, "no slots should be present"); // Slot 0, index 0 - let proposer_index_a = 420; - let sidecar_a = get_blob_sidecar(0, proposer_index_a, 0); + let key_a = 420; + let sidecar_a = get_sidecar(0, key_a, 0, fork_name); assert_eq!( - cache.observe_sidecar(&sidecar_a), + cache + .observe_sidecar(sidecar_a.as_ref()) + .map(|o| o.is_some()), Ok(false), - "can observe proposer, indicates proposer unobserved" + "can observe sidecar, indicates sidecar unobserved" ); /* @@ -224,18 +363,17 @@ mod tests { assert_eq!( cache.items.len(), 1, - "only one (validator_index, slot) tuple should be present" + "only one observation key should be present" ); - let cached_blob_indices = cache + let observation_key = + &ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_a.as_ref(), &spec).unwrap(); + + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_a, Slot::new(0))) + .get(observation_key) .expect("slot zero should be present"); - assert_eq!( - cached_blob_indices.len(), - 1, - "only one proposer should be present" - ); + assert_eq!(cached_indices.len(), 1, "only one index should be present"); /* * Check that a prune at the genesis slot does nothing. @@ -243,17 +381,16 @@ mod tests { cache.prune(Slot::new(0)); + let observation_key = + ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_a.as_ref(), &spec).unwrap(); + assert_eq!(cache.finalized_slot, 0, "finalized slot is zero"); assert_eq!(cache.items.len(), 1, "only one slot should be present"); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_a, Slot::new(0))) + .get(&observation_key) .expect("slot zero should be present"); - assert_eq!( - cached_blob_indices.len(), - 1, - "only one proposer should be present" - ); + assert_eq!(cached_indices.len(), 1, "only one index should be present"); /* * Check that a prune empties the cache @@ -272,10 +409,10 @@ mod tests { */ // First slot of finalized epoch - let block_b = get_blob_sidecar(E::slots_per_epoch(), 419, 0); + let sidecar_b = get_sidecar(E::slots_per_epoch(), 419, 0, fork_name); assert_eq!( - cache.observe_sidecar(&block_b), + cache.observe_sidecar(sidecar_b.as_ref()), Err(Error::FinalizedDataSidecar { slot: E::slots_per_epoch().into(), finalized_slot: E::slots_per_epoch().into(), @@ -286,34 +423,34 @@ mod tests { assert_eq!(cache.items.len(), 0, "sidecar was not added"); /* - * Check that we _can_ insert a non-finalized block + * Check that we _can_ insert a non-finalized sidecar */ let three_epochs = E::slots_per_epoch() * 3; - // First slot of finalized epoch - let proposer_index_b = 421; - let block_b = get_blob_sidecar(three_epochs, proposer_index_b, 0); + let key_b = 421; + let sidecar_b = get_sidecar(three_epochs, key_b, 0, fork_name); assert_eq!( - cache.observe_sidecar(&block_b), + cache + .observe_sidecar(sidecar_b.as_ref()) + .map(|o| o.is_some()), Ok(false), - "can insert non-finalized block" + "can insert non-finalized sidecar" ); + let observation_key = + ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_b.as_ref(), &spec).unwrap(); + assert_eq!(cache.items.len(), 1, "only one slot should be present"); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_b, Slot::new(three_epochs))) + .get(&observation_key) .expect("the three epochs slot should be present"); - assert_eq!( - cached_blob_indices.len(), - 1, - "only one proposer should be present" - ); + assert_eq!(cached_indices.len(), 1, "only one index should be present"); /* - * Check that a prune doesnt wipe later blocks + * Check that a prune doesnt wipe later sidecars */ let two_epochs = E::slots_per_epoch() * 2; @@ -325,183 +462,294 @@ mod tests { "finalized slot is updated" ); + let observation_key = + ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_b.as_ref(), &spec).unwrap(); + assert_eq!(cache.items.len(), 1, "only one slot should be present"); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_b, Slot::new(three_epochs))) + .get(&observation_key) .expect("the three epochs slot should be present"); - assert_eq!( - cached_blob_indices.len(), - 1, - "only one proposer should be present" - ); + assert_eq!(cached_indices.len(), 1, "only one index should be present"); } #[test] fn simple_observations() { let spec = Arc::new(test_spec::<E>()); - let mut cache = ObservedDataSidecars::<BlobSidecar<E>>::new(spec.clone()); + let fork_name = spec.fork_name_at_slot::<E>(Slot::new(0)); + + let mut cache = ObservedDataSidecars::<DataColumnSidecar<E>, E>::new(spec.clone()); // Slot 0, index 0 - let proposer_index_a = 420; - let sidecar_a = get_blob_sidecar(0, proposer_index_a, 0); + let key_a = 420; + let sidecar_a = get_sidecar(0, key_a, 0, fork_name); assert_eq!( - cache.proposer_is_known(&sidecar_a), + cache + .observation_key_is_known(sidecar_a.as_ref()) + .map(|o| o.is_some()), Ok(false), "no observation in empty cache" ); assert_eq!( - cache.observe_sidecar(&sidecar_a), + cache + .observe_sidecar(sidecar_a.as_ref()) + .map(|o| o.is_some()), Ok(false), - "can observe proposer, indicates proposer unobserved" + "can observe sidecar, indicates sidecar unobserved" ); assert_eq!( - cache.proposer_is_known(&sidecar_a), + cache + .observation_key_is_known(sidecar_a.as_ref()) + .map(|o| o.is_some()), Ok(true), - "observed block is indicated as true" + "observed sidecar is indicated as true" ); assert_eq!( - cache.observe_sidecar(&sidecar_a), + cache + .observe_sidecar(sidecar_a.as_ref()) + .map(|o| o.is_some()), Ok(true), "observing again indicates true" ); assert_eq!(cache.finalized_slot, 0, "finalized slot is zero"); assert_eq!(cache.items.len(), 1, "only one slot should be present"); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_a, Slot::new(0))) + .get( + &ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_a.as_ref(), &spec).unwrap(), + ) .expect("slot zero should be present"); - assert_eq!( - cached_blob_indices.len(), - 1, - "only one proposer should be present" - ); + assert_eq!(cached_indices.len(), 1, "only one index should be present"); - // Slot 1, proposer 0 + // Slot 1, different key - let proposer_index_b = 421; - let sidecar_b = get_blob_sidecar(1, proposer_index_b, 0); + let key_b = 421; + let sidecar_b = get_sidecar(1, key_b, 0, fork_name); assert_eq!( - cache.proposer_is_known(&sidecar_b), + cache + .observation_key_is_known(sidecar_b.as_ref()) + .map(|o| o.is_some()), Ok(false), "no observation for new slot" ); assert_eq!( - cache.observe_sidecar(&sidecar_b), + cache + .observe_sidecar(sidecar_b.as_ref()) + .map(|o| o.is_some()), Ok(false), - "can observe proposer for new slot, indicates proposer unobserved" + "can observe sidecar for new slot, indicates sidecar unobserved" ); assert_eq!( - cache.proposer_is_known(&sidecar_b), + cache + .observation_key_is_known(sidecar_b.as_ref()) + .map(|o| o.is_some()), Ok(true), - "observed block in slot 1 is indicated as true" + "observed sidecar in slot 1 is indicated as true" ); assert_eq!( - cache.observe_sidecar(&sidecar_b), + cache + .observe_sidecar(sidecar_b.as_ref()) + .map(|o| o.is_some()), Ok(true), "observing slot 1 again indicates true" ); assert_eq!(cache.finalized_slot, 0, "finalized slot is zero"); assert_eq!(cache.items.len(), 2, "two slots should be present"); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_a, Slot::new(0))) + .get( + &ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_a.as_ref(), &spec).unwrap(), + ) .expect("slot zero should be present"); assert_eq!( - cached_blob_indices.len(), + cached_indices.len(), 1, - "only one proposer should be present in slot 0" + "only one index should be present in slot 0" ); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_b, Slot::new(1))) - .expect("slot zero should be present"); + .get( + &ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_b.as_ref(), &spec).unwrap(), + ) + .expect("slot one should be present"); assert_eq!( - cached_blob_indices.len(), + cached_indices.len(), 1, - "only one proposer should be present in slot 1" + "only one index should be present in slot 1" ); - // Slot 0, index 1 - let sidecar_c = get_blob_sidecar(0, proposer_index_a, 1); + // Slot 0, index 1 (same key as sidecar_a) + let sidecar_c = get_sidecar(0, key_a, 1, fork_name); assert_eq!( - cache.proposer_is_known(&sidecar_c), + cache + .observation_key_is_known(sidecar_c.as_ref()) + .map(|o| o.is_some()), Ok(false), "no observation for new index" ); assert_eq!( - cache.observe_sidecar(&sidecar_c), + cache + .observe_sidecar(sidecar_c.as_ref()) + .map(|o| o.is_some()), Ok(false), "can observe new index, indicates sidecar unobserved for new index" ); assert_eq!( - cache.proposer_is_known(&sidecar_c), + cache + .observation_key_is_known(sidecar_c.as_ref()) + .map(|o| o.is_some()), Ok(true), "observed new sidecar is indicated as true" ); assert_eq!( - cache.observe_sidecar(&sidecar_c), + cache + .observe_sidecar(sidecar_c.as_ref()) + .map(|o| o.is_some()), Ok(true), "observing new sidecar again indicates true" ); assert_eq!(cache.finalized_slot, 0, "finalized slot is zero"); assert_eq!(cache.items.len(), 2, "two slots should be present"); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_a, Slot::new(0))) + .get( + &ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_a.as_ref(), &spec).unwrap(), + ) .expect("slot zero should be present"); assert_eq!( - cached_blob_indices.len(), + cached_indices.len(), 2, - "two blob indices should be present in slot 0" + "two indices should be present in slot 0" ); - // Create a sidecar sharing slot and proposer but with a different block root. - let mut sidecar_d: BlobSidecar<E> = BlobSidecar { - index: sidecar_c.index, - blob: sidecar_c.blob.clone(), - kzg_commitment: sidecar_c.kzg_commitment, - kzg_proof: sidecar_c.kzg_proof, - signed_block_header: sidecar_c.signed_block_header.clone(), - kzg_commitment_inclusion_proof: sidecar_c.kzg_commitment_inclusion_proof.clone(), - }; - sidecar_d.signed_block_header.message.body_root = Hash256::repeat_byte(7); + // Create a sidecar with a different key at the same slot + // For Fulu: different proposer_index creates a different observation key + // For Gloas: different block_root creates a different observation key + let key_c = 422; + let sidecar_d = get_sidecar(0, key_c, 0, fork_name); assert_eq!( - cache.proposer_is_known(&sidecar_d), - Ok(true), - "there has been an observation for this proposer index" + cache + .observation_key_is_known(sidecar_d.as_ref()) + .map(|o| o.is_some()), + Ok(false), + "no observation for new key" ); assert_eq!( - cache.observe_sidecar(&sidecar_d), - Ok(true), - "indicates sidecar proposer was observed" + cache + .observe_sidecar(sidecar_d.as_ref()) + .map(|o| o.is_some()), + Ok(false), + "can observe sidecar, indicates sidecar unobserved for new key" ); - let cached_blob_indices = cache + let cached_indices = cache .items - .get(&ProposalKey::new(proposer_index_a, Slot::new(0))) - .expect("slot zero should be present"); + .get( + &ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_d.as_ref(), &spec).unwrap(), + ) + .expect("sidecar_d's observation key should be present"); assert_eq!( - cached_blob_indices.len(), - 2, - "two blob indices should be present in slot 0" + cached_indices.len(), + 1, + "one index should be present for sidecar_d's observation key" ); // Try adding an out of bounds index - let invalid_index = spec.max_blobs_per_block(Epoch::new(0)); - let sidecar_d = get_blob_sidecar(0, proposer_index_a, invalid_index); + let invalid_index = E::number_of_columns() as u64; + let sidecar_e = get_sidecar(0, key_a, invalid_index, fork_name); assert_eq!( - cache.observe_sidecar(&sidecar_d), + cache.observe_sidecar(sidecar_e.as_ref()), Err(Error::InvalidDataIndex(invalid_index)), - "cannot add an index > MaxBlobsPerBlock" + "cannot add an index >= NUMBER_OF_COLUMNS" ); } + + /// Test that sidecars with the same observation key but different indices + /// are tracked correctly. + #[test] + fn multiple_indices_same_key() { + let spec = Arc::new(test_spec::<E>()); + let fork_name = spec.fork_name_at_slot::<E>(Slot::new(0)); + + let mut cache = ObservedDataSidecars::<DataColumnSidecar<E>, E>::new(spec.clone()); + + let key = 420; + + // Add multiple indices for the same observation key + for index in 0..5 { + let sidecar = get_sidecar(0, key, index, fork_name); + assert_eq!( + cache.observe_sidecar(sidecar.as_ref()).map(|o| o.is_some()), + Ok(false), + "index {index} should be new" + ); + } + + // Verify all indices are tracked under one observation key + assert_eq!(cache.items.len(), 1, "only one observation key"); + + let sidecar_for_key = get_sidecar(0, key, 0, fork_name); + let observation_key = + ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar_for_key.as_ref(), &spec) + .unwrap(); + let cached_indices = cache.items.get(&observation_key).unwrap(); + assert_eq!(cached_indices.len(), 5, "five indices should be tracked"); + + // Re-observing should indicate they're already known + for index in 0..5 { + let sidecar = get_sidecar(0, key, index, fork_name); + assert_eq!( + cache.observe_sidecar(sidecar.as_ref()).map(|o| o.is_some()), + Ok(true), + "index {index} should already be known" + ); + } + } + + /// Test the known_for_observation_key method + #[test] + fn known_for_observation_key() { + let spec = Arc::new(test_spec::<E>()); + let fork_name = spec.fork_name_at_slot::<E>(Slot::new(0)); + + let mut cache = ObservedDataSidecars::<DataColumnSidecar<E>, E>::new(spec.clone()); + + let key = 420; + let sidecar = get_sidecar(0, key, 0, fork_name); + let observation_key = + ObservationKey::new::<DataColumnSidecar<E>, E>(sidecar.as_ref(), &spec).unwrap(); + + // Before observation, should return None + assert!(cache.known_for_observation_key(&observation_key).is_none()); + + // After observation, should return the set of indices + cache.observe_sidecar(sidecar.as_ref()).unwrap(); + let known = cache + .known_for_observation_key(&observation_key) + .expect("should be known"); + assert!(known.contains(&0)); + assert_eq!(known.len(), 1); + + // Add more indices + let sidecar_1 = get_sidecar(0, key, 1, fork_name); + let sidecar_2 = get_sidecar(0, key, 2, fork_name); + cache.observe_sidecar(sidecar_1.as_ref()).unwrap(); + cache.observe_sidecar(sidecar_2.as_ref()).unwrap(); + + let known = cache + .known_for_observation_key(&observation_key) + .expect("should be known"); + assert!(known.contains(&0)); + assert!(known.contains(&1)); + assert!(known.contains(&2)); + assert_eq!(known.len(), 3); + } } diff --git a/beacon_node/beacon_chain/src/otb_verification_service.rs b/beacon_node/beacon_chain/src/otb_verification_service.rs deleted file mode 100644 index e02705f5da..0000000000 --- a/beacon_node/beacon_chain/src/otb_verification_service.rs +++ /dev/null @@ -1,369 +0,0 @@ -use crate::execution_payload::{validate_merge_block, AllowOptimisticImport}; -use crate::{ - BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, ExecutionPayloadError, - INVALID_FINALIZED_MERGE_TRANSITION_BLOCK_SHUTDOWN_REASON, -}; -use itertools::process_results; -use logging::crit; -use proto_array::InvalidationOperation; -use slot_clock::SlotClock; -use ssz::{Decode, Encode}; -use ssz_derive::{Decode, Encode}; -use state_processing::per_block_processing::is_merge_transition_complete; -use std::sync::Arc; -use store::{DBColumn, Error as StoreError, HotColdDB, KeyValueStore, StoreItem}; -use task_executor::{ShutdownReason, TaskExecutor}; -use tokio::time::sleep; -use tracing::{debug, error, info, warn}; -use tree_hash::TreeHash; -use types::{BeaconBlockRef, EthSpec, Hash256, Slot}; -use DBColumn::OptimisticTransitionBlock as OTBColumn; - -#[derive(Clone, Debug, Decode, Encode, PartialEq)] -pub struct OptimisticTransitionBlock { - root: Hash256, - slot: Slot, -} - -impl OptimisticTransitionBlock { - // types::BeaconBlockRef<'_, <T as BeaconChainTypes>::EthSpec> - pub fn from_block<E: EthSpec>(block: BeaconBlockRef<E>) -> Self { - Self { - root: block.tree_hash_root(), - slot: block.slot(), - } - } - - pub fn root(&self) -> &Hash256 { - &self.root - } - - pub fn slot(&self) -> &Slot { - &self.slot - } - - pub fn persist_in_store<T, A>(&self, store: A) -> Result<(), StoreError> - where - T: BeaconChainTypes, - A: AsRef<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, - { - if store - .as_ref() - .item_exists::<OptimisticTransitionBlock>(&self.root)? - { - Ok(()) - } else { - store.as_ref().put_item(&self.root, self) - } - } - - pub fn remove_from_store<T, A>(&self, store: A) -> Result<(), StoreError> - where - T: BeaconChainTypes, - A: AsRef<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, - { - store - .as_ref() - .hot_db - .key_delete(OTBColumn.into(), self.root.as_slice()) - } - - fn is_canonical<T: BeaconChainTypes>( - &self, - chain: &BeaconChain<T>, - ) -> Result<bool, BeaconChainError> { - Ok(chain - .forwards_iter_block_roots_until(self.slot, self.slot)? - .next() - .transpose()? - .map(|(root, _)| root) - == Some(self.root)) - } -} - -impl StoreItem for OptimisticTransitionBlock { - fn db_column() -> DBColumn { - OTBColumn - } - - fn as_store_bytes(&self) -> Vec<u8> { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result<Self, StoreError> { - Ok(Self::from_ssz_bytes(bytes)?) - } -} - -/// The routine is expected to run once per epoch, 1/4th through the epoch. -pub const EPOCH_DELAY_FACTOR: u32 = 4; - -/// Spawns a routine which checks the validity of any optimistically imported transition blocks -/// -/// This routine will run once per epoch, at `epoch_duration / EPOCH_DELAY_FACTOR` after -/// the start of each epoch. -/// -/// The service will not be started if there is no `execution_layer` on the `chain`. -pub fn start_otb_verification_service<T: BeaconChainTypes>( - executor: TaskExecutor, - chain: Arc<BeaconChain<T>>, -) { - // Avoid spawning the service if there's no EL, it'll just error anyway. - if chain.execution_layer.is_some() { - executor.spawn( - async move { otb_verification_service(chain).await }, - "otb_verification_service", - ); - } -} - -pub fn load_optimistic_transition_blocks<T: BeaconChainTypes>( - chain: &BeaconChain<T>, -) -> Result<Vec<OptimisticTransitionBlock>, StoreError> { - process_results( - chain.store.hot_db.iter_column::<Hash256>(OTBColumn), - |iter| { - iter.map(|(_, bytes)| OptimisticTransitionBlock::from_store_bytes(&bytes)) - .collect() - }, - )? -} - -#[derive(Debug)] -pub enum Error { - ForkChoice(String), - BeaconChain(BeaconChainError), - StoreError(StoreError), - NoBlockFound(OptimisticTransitionBlock), -} - -pub async fn validate_optimistic_transition_blocks<T: BeaconChainTypes>( - chain: &Arc<BeaconChain<T>>, - otbs: Vec<OptimisticTransitionBlock>, -) -> Result<(), Error> { - let finalized_slot = chain - .canonical_head - .fork_choice_read_lock() - .get_finalized_block() - .map_err(|e| Error::ForkChoice(format!("{:?}", e)))? - .slot; - - // separate otbs into - // non-canonical - // finalized canonical - // unfinalized canonical - let mut non_canonical_otbs = vec![]; - let (finalized_canonical_otbs, unfinalized_canonical_otbs) = process_results( - otbs.into_iter().map(|otb| { - otb.is_canonical(chain) - .map(|is_canonical| (otb, is_canonical)) - }), - |pair_iter| { - pair_iter - .filter_map(|(otb, is_canonical)| { - if is_canonical { - Some(otb) - } else { - non_canonical_otbs.push(otb); - None - } - }) - .partition::<Vec<_>, _>(|otb| *otb.slot() <= finalized_slot) - }, - ) - .map_err(Error::BeaconChain)?; - - // remove non-canonical blocks that conflict with finalized checkpoint from the database - for otb in non_canonical_otbs { - if *otb.slot() <= finalized_slot { - otb.remove_from_store::<T, _>(&chain.store) - .map_err(Error::StoreError)?; - } - } - - // ensure finalized canonical otb are valid, otherwise kill client - for otb in finalized_canonical_otbs { - match chain.get_block(otb.root()).await { - Ok(Some(block)) => { - match validate_merge_block(chain, block.message(), AllowOptimisticImport::No).await - { - Ok(()) => { - // merge transition block is valid, remove it from OTB - otb.remove_from_store::<T, _>(&chain.store) - .map_err(Error::StoreError)?; - info!( - block_root = %otb.root(), - "type" = "finalized", - "Validated merge transition block" - ); - } - // The block was not able to be verified by the EL. Leave the OTB in the - // database since the EL is likely still syncing and may verify the block - // later. - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::UnverifiedNonOptimisticCandidate, - )) => (), - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::InvalidTerminalPoWBlock { .. }, - )) => { - // Finalized Merge Transition Block is Invalid! Kill the Client! - crit!( - msg = "You must use the `--purge-db` flag to clear the database and restart sync. \ - You may be on a hostile network.", - block_hash = ?block.canonical_root(), - "Finalized merge transition block is invalid!" - ); - let mut shutdown_sender = chain.shutdown_sender(); - if let Err(e) = shutdown_sender.try_send(ShutdownReason::Failure( - INVALID_FINALIZED_MERGE_TRANSITION_BLOCK_SHUTDOWN_REASON, - )) { - crit!( - error = ?e, - shutdown_reason = INVALID_FINALIZED_MERGE_TRANSITION_BLOCK_SHUTDOWN_REASON, - "Failed to shut down client" - ); - } - } - _ => {} - } - } - Ok(None) => return Err(Error::NoBlockFound(otb)), - // Our database has pruned the payload and the payload was unavailable on the EL since - // the EL is still syncing or the payload is non-canonical. - Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => (), - Err(e) => return Err(Error::BeaconChain(e)), - } - } - - // attempt to validate any non-finalized canonical otb blocks - for otb in unfinalized_canonical_otbs { - match chain.get_block(otb.root()).await { - Ok(Some(block)) => { - match validate_merge_block(chain, block.message(), AllowOptimisticImport::No).await - { - Ok(()) => { - // merge transition block is valid, remove it from OTB - otb.remove_from_store::<T, _>(&chain.store) - .map_err(Error::StoreError)?; - info!( - block_root = ?otb.root(), - "type" = "not finalized", - "Validated merge transition block" - ); - } - // The block was not able to be verified by the EL. Leave the OTB in the - // database since the EL is likely still syncing and may verify the block - // later. - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::UnverifiedNonOptimisticCandidate, - )) => (), - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::InvalidTerminalPoWBlock { .. }, - )) => { - // Unfinalized Merge Transition Block is Invalid -> Run process_invalid_execution_payload - warn!( - block_root = ?otb.root(), - "Merge transition block invalid" - ); - chain - .process_invalid_execution_payload( - &InvalidationOperation::InvalidateOne { - block_root: *otb.root(), - }, - ) - .await - .map_err(|e| { - warn!( - error = ?e, - location = "process_invalid_execution_payload", - "Error checking merge transition block" - ); - Error::BeaconChain(e) - })?; - } - _ => {} - } - } - Ok(None) => return Err(Error::NoBlockFound(otb)), - // Our database has pruned the payload and the payload was unavailable on the EL since - // the EL is still syncing or the payload is non-canonical. - Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => (), - Err(e) => return Err(Error::BeaconChain(e)), - } - } - - Ok(()) -} - -/// Loop until any optimistically imported merge transition blocks have been verified and -/// the merge has been finalized. -async fn otb_verification_service<T: BeaconChainTypes>(chain: Arc<BeaconChain<T>>) { - let epoch_duration = chain.slot_clock.slot_duration() * T::EthSpec::slots_per_epoch() as u32; - loop { - match chain - .slot_clock - .duration_to_next_epoch(T::EthSpec::slots_per_epoch()) - { - Some(duration) => { - let additional_delay = epoch_duration / EPOCH_DELAY_FACTOR; - sleep(duration + additional_delay).await; - - debug!("OTB verification service firing"); - - if !is_merge_transition_complete( - &chain.canonical_head.cached_head().snapshot.beacon_state, - ) { - // We are pre-merge. Nothing to do yet. - continue; - } - - // load all optimistically imported transition blocks from the database - match load_optimistic_transition_blocks(chain.as_ref()) { - Ok(otbs) => { - if otbs.is_empty() { - if chain - .canonical_head - .fork_choice_read_lock() - .get_finalized_block() - .map_or(false, |block| { - block.execution_status.is_execution_enabled() - }) - { - // there are no optimistic blocks in the database, we can exit - // the service since the merge transition is finalized and we'll - // never see another transition block - break; - } else { - debug!( - info = "waiting for the merge transition to finalize", - "No optimistic transition blocks" - ) - } - } - if let Err(e) = validate_optimistic_transition_blocks(&chain, otbs).await { - warn!( - error = ?e, - "Error while validating optimistic transition blocks" - ); - } - } - Err(e) => { - error!( - error = ?e, - "Error loading optimistic transition blocks" - ); - } - }; - } - None => { - error!("Failed to read slot clock"); - // If we can't read the slot clock, just wait another slot. - sleep(chain.slot_clock.slot_duration()).await; - } - }; - } - debug!( - msg = "shutting down OTB verification service", - "No optimistic transition blocks in database" - ); -} diff --git a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs new file mode 100644 index 0000000000..0ce754c8a0 --- /dev/null +++ b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs @@ -0,0 +1,569 @@ +use crate::data_column_verification::{ + KzgVerifiedCustodyDataColumn, KzgVerifiedCustodyPartialDataColumn, +}; +use lru::LruCache; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tracing::error; +use types::core::{Epoch, EthSpec, Hash256}; +use types::data::{ColumnIndex, PartialDataColumnHeader}; + +/// Assembles partial data columns into complete columns +pub struct PartialDataColumnAssembler<E: EthSpec> { + /// Cache of assemblies keyed by block root + assemblies: RwLock<LruCache<Hash256, PartialAssembly<E>>>, +} + +/// Tracks partial columns being assembled for a single block +struct PartialAssembly<E: EthSpec> { + header: Arc<PartialDataColumnHeader<E>>, + has_local_blobs: bool, + /// Map of column_index -> partial column being assembled + columns: HashMap<ColumnIndex, AssemblyColumn<E>>, +} + +#[derive(Clone, Debug)] +pub enum AssemblyColumn<E: EthSpec> { + // As the actual column is Arc'd inside, storing it redundantly here will not increase memory usage. + Complete(KzgVerifiedCustodyDataColumn<E>), + Incomplete(KzgVerifiedCustodyPartialDataColumn<E>), +} + +/// Result of merging a partial column +pub struct PartialMergeResult<E: EthSpec> { + /// How many cells were added to the store + pub added_cells: usize, + /// Have local blobs been added yet + pub local_blobs: bool, + /// Merge that completed the column + pub full_columns: Vec<KzgVerifiedCustodyDataColumn<E>>, + /// The updated partials for publishing + pub updated_partials: Vec<KzgVerifiedCustodyPartialDataColumn<E>>, +} + +impl<E: EthSpec> PartialDataColumnAssembler<E> { + pub fn new(capacity: NonZeroUsize) -> Self { + Self { + assemblies: RwLock::new(LruCache::new(capacity)), + } + } + + /// Insert a `header` for the given `block_root` into the assembler. + /// Returns true unless there already is a header for the block root. + pub fn init(&self, block_root: Hash256, header: Arc<PartialDataColumnHeader<E>>) -> bool { + let mut assemblies = self.assemblies.write(); + + if assemblies.contains(&block_root) { + return false; + } + + let assembly = PartialAssembly { + header, + has_local_blobs: false, + columns: HashMap::new(), + }; + + assemblies.put(block_root, assembly); + + true + } + + /// Merge one or more received partial columns into the assembly. + /// Returns the merge result indicating if the columns are now complete. + pub fn merge_partials( + &self, + block_root: Hash256, + partials: Vec<KzgVerifiedCustodyPartialDataColumn<E>>, + header: Arc<PartialDataColumnHeader<E>>, + ) -> Option<PartialMergeResult<E>> { + let mut assemblies = self.assemblies.write(); + let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { + header: header.clone(), + has_local_blobs: false, + columns: HashMap::new(), + }); + + let mut full_columns = Vec::new(); + let mut updated_partials = Vec::new(); + let mut added_cells = 0; + + for partial in partials { + let partial_column = partial.as_data_column(); + let column_index = partial_column.index; + + let merged = if let Some(existing) = assembly.columns.get(&column_index) { + let AssemblyColumn::Incomplete(existing) = existing else { + // Already complete. + continue; + }; + let column = existing.as_data_column(); + + let old_len = column.sidecar.column.len(); + + // Merge with existing partial + let merged = match existing.merge(&partial) { + Ok(merged) => merged, + Err(err) => { + error!("Unexpected error merging partial data column: {:?}", err); + continue; + } + }; + + let adding_cells = merged + .as_data_column() + .sidecar + .column + .len() + .saturating_sub(old_len); + + added_cells += adding_cells; + + if adding_cells == 0 { + continue; + } + + merged + } else { + added_cells += partial_column.sidecar.column.len(); + // First time seeing this column index for this block + partial + }; + + // Check if merged column is now complete by trying to convert into full + let column = if let Some(full_column) = merged.try_clone_full(&header) { + full_columns.push(full_column.clone()); + AssemblyColumn::Complete(full_column) + } else { + AssemblyColumn::Incomplete(merged.clone()) + }; + + // Update assembly with merged partial + assembly.columns.insert(column_index, column); + updated_partials.push(merged); + } + + Some(PartialMergeResult { + added_cells, + local_blobs: assembly.has_local_blobs, + full_columns, + updated_partials, + }) + } + + /// Mark a column as assembled. Returns true if the column was previously incomplete or not + /// in the assembly at all. + pub fn mark_as_complete( + &self, + block_root: Hash256, + column: &KzgVerifiedCustodyDataColumn<E>, + ) -> bool { + // TODO(gloas): support partial messages + let Ok(fulu) = column.as_data_column().as_fulu() else { + return false; + }; + + let mut assemblies = self.assemblies.write(); + let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { + header: Arc::new(PartialDataColumnHeader { + kzg_commitments: fulu.kzg_commitments.clone(), + signed_block_header: fulu.signed_block_header.clone(), + kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), + }), + has_local_blobs: false, + columns: Default::default(), + }); + let prev = assembly + .columns + .insert(column.index(), AssemblyColumn::Complete(column.clone())); + !matches!(prev, Some(AssemblyColumn::Complete(_))) + } + + /// Returns true if the given column is complete. + pub fn is_complete(&self, block_root: Hash256, column_index: ColumnIndex) -> bool { + self.assemblies.read().peek(&block_root).is_some_and(|a| { + matches!( + a.columns.get(&column_index), + Some(AssemblyColumn::Complete(_)) + ) + }) + } + + /// Get the current partial for a specific column if it exists in assembly + pub fn get_partial( + &self, + block_root: &Hash256, + column_index: ColumnIndex, + ) -> Option<AssemblyColumn<E>> { + self.assemblies + .read() + .peek(block_root)? + .columns + .get(&column_index) + .cloned() + } + + /// Get all current partials for a block for publishing after fetching local blobs. + /// To unlock future publishing, mark blobs as fetched locally. + /// We do this within one write lock to avoid useless double publishes. + pub fn get_partials_and_mark_as_local_fetched( + &self, + block_root: Hash256, + header: &Arc<PartialDataColumnHeader<E>>, + ) -> Vec<KzgVerifiedCustodyPartialDataColumn<E>> { + let mut assemblies = self.assemblies.write(); + let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { + header: header.clone(), + has_local_blobs: true, + columns: Default::default(), + }); + + assembly.has_local_blobs = true; + + assembly + .columns + .values() + .filter_map(|value| { + if let AssemblyColumn::Incomplete(partial) = value { + Some(partial.clone()) + } else { + None + } + }) + .collect() + } + + /// Get header for a block if we have an active assembly + pub fn get_header(&self, block_root: &Hash256) -> Option<Arc<PartialDataColumnHeader<E>>> { + self.assemblies + .read() + .peek(block_root) + .map(|a| a.header.clone()) + } + + /// Maintenance: remove assemblies older than cutoff epoch + pub fn do_maintenance(&self, cutoff_epoch: Epoch) { + let mut assemblies = self.assemblies.write(); + let mut to_remove = vec![]; + + for (root, assembly) in assemblies.iter() { + if assembly + .header + .signed_block_header + .message + .slot + .epoch(E::slots_per_epoch()) + < cutoff_epoch + { + to_remove.push(*root); + } + } + + for root in to_remove { + assemblies.pop(&root); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_column_verification::{ + KzgVerifiedCustodyPartialDataColumn, KzgVerifiedDataColumn, KzgVerifiedPartialDataColumn, + }; + use bls::{FixedBytesExtended, Signature}; + use kzg::{KzgCommitment, KzgProof}; + use ssz_types::{FixedVector, VariableList}; + use types::block::{BeaconBlockHeader, SignedBeaconBlockHeader}; + use types::core::{EthSpec, Hash256, MinimalEthSpec, Slot}; + use types::data::{ + Cell, CellBitmap, DataColumnSidecar, DataColumnSidecarFulu, PartialDataColumn, + PartialDataColumnSidecar, + }; + + type E = MinimalEthSpec; + + fn make_cell(marker: u8) -> Cell<E> { + let mut cell = Cell::<E>::default(); + cell[0] = marker; + cell + } + + fn make_header(num_commitments: usize) -> PartialDataColumnHeader<E> { + PartialDataColumnHeader { + kzg_commitments: vec![KzgCommitment([0u8; 48]); num_commitments] + .try_into() + .unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + } + } + + fn make_partial( + block_root: Hash256, + column_index: ColumnIndex, + total_blobs: usize, + present_indices: &[usize], + ) -> KzgVerifiedCustodyPartialDataColumn<E> { + make_partial_with_header(block_root, column_index, total_blobs, present_indices, true) + } + + fn make_partial_with_header( + block_root: Hash256, + column_index: ColumnIndex, + total_blobs: usize, + present_indices: &[usize], + include_header: bool, + ) -> KzgVerifiedCustodyPartialDataColumn<E> { + let mut bitmap = CellBitmap::<E>::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + let column: VariableList<_, _> = present_indices + .iter() + .map(|&idx| make_cell(idx as u8)) + .collect::<Vec<_>>() + .try_into() + .unwrap(); + let proofs: VariableList<_, _> = present_indices + .iter() + .map(|_| KzgProof::empty()) + .collect::<Vec<_>>() + .try_into() + .unwrap(); + + let header = include_header.then(|| make_header(total_blobs)).into(); + + let partial = PartialDataColumn { + block_root, + index: column_index, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column, + kzg_proofs: proofs, + header, + }, + }; + KzgVerifiedCustodyPartialDataColumn::from_asserted_custody( + KzgVerifiedPartialDataColumn::__new_for_testing(Arc::new(partial)), + ) + } + + fn make_full_column(fulu: DataColumnSidecarFulu<E>) -> KzgVerifiedCustodyDataColumn<E> { + KzgVerifiedCustodyDataColumn::from_asserted_custody( + KzgVerifiedDataColumn::__new_for_testing(Arc::new(DataColumnSidecar::Fulu(fulu))), + ) + } + + fn make_assembler() -> PartialDataColumnAssembler<E> { + PartialDataColumnAssembler::new(NonZeroUsize::new(16).unwrap()) + } + + // -- init and get_header tests -- + + #[test] + fn init_stores_header() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = make_header(4); + assert!(assembler.init(root, Arc::new(header.clone()))); + let retrieved = assembler.get_header(&root).unwrap(); + assert_eq!(*retrieved, header); + } + + #[test] + fn init_returns_false_if_already_exists() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + assert!(assembler.init(root, header.clone())); + assert!(!assembler.init(root, header)); + } + + // -- merge_partials tests -- + + #[test] + fn merge_partials_tracks_added_cells() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + let partial = make_partial(root, 0, 4, &[0, 1, 2]); + let result = assembler + .merge_partials(root, vec![partial], header.clone()) + .unwrap(); + assert_eq!(result.added_cells, 3); + + // Merge more cells for the same column + let partial2 = make_partial(root, 0, 4, &[2, 3]); + let result2 = assembler + .merge_partials(root, vec![partial2], header) + .unwrap(); + // Only cell 3 is new (cell 2 was already present) + assert_eq!(result2.added_cells, 1); + } + + #[test] + fn merge_partials_ignores_already_complete_column() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + // Complete the column + let partial = make_partial(root, 0, 4, &[0, 1, 2, 3]); + let result = assembler + .merge_partials(root, vec![partial], header.clone()) + .unwrap(); + assert_eq!(result.added_cells, 4); + assert_eq!(result.full_columns.len(), 1); + + // Try to merge more — should be ignored + let partial2 = make_partial(root, 0, 4, &[0, 1]); + let result2 = assembler + .merge_partials(root, vec![partial2], header) + .unwrap(); + assert_eq!(result2.added_cells, 0); + assert!(result2.full_columns.is_empty()); + } + + #[test] + fn merge_partials_completes_column_progressively() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + let partial1 = make_partial(root, 0, 4, &[0, 1]); + let result1 = assembler + .merge_partials(root, vec![partial1], header.clone()) + .unwrap(); + assert!(result1.full_columns.is_empty()); + + let partial2 = make_partial(root, 0, 4, &[2, 3]); + let result2 = assembler + .merge_partials(root, vec![partial2], header) + .unwrap(); + assert_eq!(result2.full_columns.len(), 1); + } + + #[test] + fn merge_partials_returns_updated_partials() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + let partial = make_partial(root, 0, 4, &[0, 2]); + let result = assembler + .merge_partials(root, vec![partial], header) + .unwrap(); + assert_eq!(result.updated_partials.len(), 1); + assert_eq!(result.updated_partials[0].index(), 0); + } + + // -- mark_as_complete tests -- + + #[test] + fn mark_as_complete_replaces_incomplete() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + // Merge an incomplete partial first + let partial = make_partial(root, 0, 4, &[0, 1]); + assembler.merge_partials(root, vec![partial], header); + + let full_column = make_full_column(DataColumnSidecarFulu::<E> { + index: 0, + column: vec![Cell::<E>::default(); 4].try_into().unwrap(), + kzg_commitments: vec![KzgCommitment([0u8; 48]); 4].try_into().unwrap(), + kzg_proofs: vec![KzgProof::empty(); 4].try_into().unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + }); + assert!(assembler.mark_as_complete(root, &full_column)); + } + + #[test] + fn mark_as_complete_returns_false_if_already_complete() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + + let full_column = make_full_column(DataColumnSidecarFulu::<E> { + index: 0, + column: vec![Cell::<E>::default(); 4].try_into().unwrap(), + kzg_commitments: vec![KzgCommitment([0u8; 48]); 4].try_into().unwrap(), + kzg_proofs: vec![KzgProof::empty(); 4].try_into().unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + }); + assert!(assembler.mark_as_complete(root, &full_column)); + assert!(!assembler.mark_as_complete(root, &full_column)); + } + + // -- do_maintenance tests -- + + #[test] + fn do_maintenance_removes_old_assemblies() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + // Header at slot 0 → epoch 0 + let header = Arc::new(make_header(4)); + assembler.init(root, header); + assert!(assembler.get_header(&root).is_some()); + + // Cutoff epoch 1 removes epoch 0 + assembler.do_maintenance(Epoch::new(1)); + assert!(assembler.get_header(&root).is_none()); + } + + #[test] + fn do_maintenance_keeps_recent_assemblies() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + // Header at slot 100 → epoch 100/8 = 12 for MinimalEthSpec (8 slots/epoch) + let mut header = make_header(4); + header.signed_block_header.message.slot = Slot::new(100); + let header = Arc::new(header); + assembler.init(root, header); + + assembler.do_maintenance(Epoch::new(1)); + assert!(assembler.get_header(&root).is_some()); + } +} diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs new file mode 100644 index 0000000000..c36c73b344 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs @@ -0,0 +1,271 @@ +use super::Error; +use crate::beacon_chain::BeaconStore; +use crate::canonical_head::CanonicalHead; +use crate::observed_attesters::ObservedPayloadAttesters; +use crate::validator_pubkey_cache::ValidatorPubkeyCache; +use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; +use bls::AggregateSignature; +use educe::Educe; +use eth2::types::{EventKind, ForkVersionedResponse}; +use parking_lot::RwLock; +use safe_arith::SafeArith; +use slot_clock::SlotClock; +use state_processing::per_block_processing::signature_sets::indexed_payload_attestation_signature_set; +use state_processing::state_advance::partial_state_advance; +use std::borrow::Cow; +use types::{ChainSpec, EthSpec, IndexedPayloadAttestation, PTC, PayloadAttestationMessage, Slot}; + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, + pub observed_payload_attesters: &'a RwLock<ObservedPayloadAttesters<T::EthSpec>>, + pub canonical_head: &'a CanonicalHead<T>, + pub validator_pubkey_cache: &'a RwLock<ValidatorPubkeyCache<T>>, + pub store: &'a BeaconStore<T>, +} + +/// A `PayloadAttestationMessage` that has been verified for propagation on the gossip network. +#[derive(Educe)] +#[educe(Clone, Debug)] +pub struct VerifiedPayloadAttestationMessage<T: BeaconChainTypes> { + payload_attestation_message: PayloadAttestationMessage, + indexed_payload_attestation: IndexedPayloadAttestation<T::EthSpec>, + ptc: PTC<T::EthSpec>, +} + +impl<T: BeaconChainTypes> VerifiedPayloadAttestationMessage<T> { + pub fn new( + payload_attestation_message: PayloadAttestationMessage, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result<Self, Error> { + let slot = payload_attestation_message.data.slot; + let validator_index = payload_attestation_message.validator_index; + + // [IGNORE] `data.slot` is within the `MAXIMUM_GOSSIP_CLOCK_DISPARITY` allowance. + verify_propagation_slot_range(ctx.slot_clock, slot, ctx.spec)?; + + // [IGNORE] There has been no other valid payload attestation message for this + // validator index. + if ctx + .observed_payload_attesters + .read() + .validator_has_been_observed(slot, validator_index as usize) + .map_err(BeaconChainError::from)? + { + return Err(Error::PriorPayloadAttestationMessageKnown { + validator_index, + slot, + }); + } + + // [IGNORE] `data.beacon_block_root` has been seen + // [REJECT] `data.beacon_block_root` passes validation. + // + // TODO(gloas): These two conditions are conflated. We need a status table to + // differentiate between: + // 1. Blocks we haven't seen (IGNORE), and + // 2. Blocks we've seen that are invalid (REJECT). + // Presently both cases return IGNORE. + let beacon_block_root = payload_attestation_message.data.beacon_block_root; + if ctx + .canonical_head + .fork_choice_read_lock() + .get_block(&beacon_block_root) + .is_none() + { + return Err(Error::UnknownHeadBlock { beacon_block_root }); + } + + // Get head state for PTC computation. If the cached head state is too stale + // (e.g. during liveness failures with many skipped slots), fall back to loading + // a more recent state from the store and advancing it if necessary. + let head = ctx.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + + let message_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let state_epoch = head_state.current_epoch(); + + // get_ptc can serve epochs in [state_epoch - 1, state_epoch + min_seed_lookahead]. + // If the message epoch is beyond that range, the head state is stale. + let advanced_state = if message_epoch + > state_epoch + .safe_add(ctx.spec.min_seed_lookahead) + .map_err(BeaconChainError::from)? + { + let head_block_root = head.head_block_root(); + let target_slot = message_epoch.start_slot(T::EthSpec::slots_per_epoch()); + + let (state_root, mut state) = ctx + .store + .get_advanced_hot_state( + head_block_root, + target_slot, + head.snapshot.beacon_state_root(), + ) + .map_err(BeaconChainError::from)? + .ok_or(BeaconChainError::MissingBeaconState( + head.snapshot.beacon_state_root(), + ))?; + + if state + .current_epoch() + .safe_add(ctx.spec.min_seed_lookahead) + .map_err(BeaconChainError::from)? + < message_epoch + { + partial_state_advance(&mut state, Some(state_root), target_slot, ctx.spec) + .map_err(BeaconChainError::from)?; + } + + Some(state) + } else { + None + }; + + let state = advanced_state.as_ref().unwrap_or(head_state); + + // [REJECT] `validator_index` is within `get_ptc(state, data.slot)`. + let ptc = state.get_ptc(slot, ctx.spec)?; + if !ptc.0.contains(&(validator_index as usize)) { + return Err(Error::NotInPTC { + validator_index, + slot, + }); + } + + // Build the indexed form for signature verification and downstream fork choice. + let indexed_payload_attestation = IndexedPayloadAttestation { + attesting_indices: vec![validator_index] + .try_into() + .map_err(|_| Error::UnknownValidatorIndex(validator_index))?, + data: payload_attestation_message.data.clone(), + signature: AggregateSignature::from(&payload_attestation_message.signature), + }; + + { + // [REJECT] The signature is valid with respect to the `validator_index`. + let pubkey_cache = ctx.validator_pubkey_cache.read(); + let signature_set = indexed_payload_attestation_signature_set( + state, + |validator_index| pubkey_cache.get(validator_index).map(Cow::Borrowed), + &indexed_payload_attestation.signature, + &indexed_payload_attestation, + ctx.spec, + ) + .map_err(|_| Error::UnknownValidatorIndex(validator_index))?; + + if !signature_set.verify() { + return Err(Error::InvalidSignature); + } + } + + // Record that we have received a valid payload attestation message from this + // validator. Double check with the write lock to handle race conditions. + if ctx + .observed_payload_attesters + .write() + .observe_validator(slot, validator_index as usize, ()) + .map_err(BeaconChainError::from)? + { + return Err(Error::PriorPayloadAttestationMessageKnown { + validator_index, + slot, + }); + } + + Ok(Self { + payload_attestation_message, + indexed_payload_attestation, + ptc, + }) + } + + pub fn payload_attestation_message(&self) -> &PayloadAttestationMessage { + &self.payload_attestation_message + } + + pub fn indexed_payload_attestation(&self) -> &IndexedPayloadAttestation<T::EthSpec> { + &self.indexed_payload_attestation + } + + pub fn ptc(&self) -> &PTC<T::EthSpec> { + &self.ptc + } + + pub fn into_payload_attestation_message(self) -> PayloadAttestationMessage { + self.payload_attestation_message + } +} + +impl<T: BeaconChainTypes> BeaconChain<T> { + pub fn payload_attestation_gossip_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + slot_clock: &self.slot_clock, + spec: &self.spec, + observed_payload_attesters: &self.observed_payload_attesters, + canonical_head: &self.canonical_head, + validator_pubkey_cache: &self.validator_pubkey_cache, + store: &self.store, + } + } + + pub fn verify_payload_attestation_message_for_gossip( + &self, + payload_attestation_message: PayloadAttestationMessage, + ) -> Result<VerifiedPayloadAttestationMessage<T>, Error> { + metrics::inc_counter(&metrics::PAYLOAD_ATTESTATION_PROCESSING_REQUESTS); + let _timer = metrics::start_timer(&metrics::PAYLOAD_ATTESTATION_GOSSIP_VERIFICATION_TIMES); + + let ctx = self.payload_attestation_gossip_context(); + VerifiedPayloadAttestationMessage::new(payload_attestation_message, &ctx).inspect( + |verified| { + metrics::inc_counter(&metrics::PAYLOAD_ATTESTATION_PROCESSING_SUCCESSES); + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_payload_attestation_message_subscribers() + { + let msg = verified.payload_attestation_message(); + event_handler.register(EventKind::PayloadAttestationMessage(Box::new( + ForkVersionedResponse { + version: self.spec.fork_name_at_slot::<T::EthSpec>(msg.data.slot), + metadata: Default::default(), + data: msg.clone(), + }, + ))); + } + }, + ) + } +} + +/// Verify that the `slot` is within the acceptable gossip propagation range, with reference +/// to the current slot of the clock. +/// +/// Accounts for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. +fn verify_propagation_slot_range<S: SlotClock>( + slot_clock: &S, + message_slot: Slot, + spec: &ChainSpec, +) -> Result<(), Error> { + let latest_permissible_slot = slot_clock + .now_with_future_tolerance(spec.maximum_gossip_clock_disparity()) + .ok_or(BeaconChainError::UnableToReadSlot)?; + if message_slot > latest_permissible_slot { + return Err(Error::FutureSlot { + message_slot, + latest_permissible_slot, + }); + } + + let earliest_permissible_slot = slot_clock + .now_with_past_tolerance(spec.maximum_gossip_clock_disparity()) + .ok_or(BeaconChainError::UnableToReadSlot)?; + if message_slot < earliest_permissible_slot { + return Err(Error::PastSlot { + message_slot, + earliest_permissible_slot, + }); + } + + Ok(()) +} diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs new file mode 100644 index 0000000000..477527c0aa --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs @@ -0,0 +1,110 @@ +//! Provides verification for `PayloadAttestationMessage` received from the gossip network. +//! +//! ```ignore +//! types::PayloadAttestationMessage +//! | +//! ▼ +//! VerifiedPayloadAttestationMessage +//! ``` + +use crate::BeaconChainError; +use strum::AsRefStr; +use types::{BeaconStateError, Hash256, Slot}; + +pub mod gossip_verified_payload_attestation; + +pub use gossip_verified_payload_attestation::{ + GossipVerificationContext, VerifiedPayloadAttestationMessage, +}; + +/// Returned when a payload attestation message was not successfully verified. It might not have +/// been verified for two reasons: +/// +/// - The message is malformed or inappropriate for the context (indicated by all variants +/// other than `BeaconChainError`). +/// - The application encountered an internal error whilst attempting to determine validity +/// (the `BeaconChainError` variant) +#[derive(Debug, AsRefStr)] +pub enum Error { + /// The payload attestation message is from a slot that is later than the current slot + /// (with respect to the gossip clock disparity). + /// + /// ## Peer scoring + /// + /// Assuming the local clock is correct, the peer has sent an invalid message. + FutureSlot { + message_slot: Slot, + latest_permissible_slot: Slot, + }, + /// The payload attestation message is from a slot that is prior to the earliest + /// permissible slot (with respect to the gossip clock disparity). + /// + /// ## Peer scoring + /// + /// Assuming the local clock is correct, the peer has sent an invalid message. + PastSlot { + message_slot: Slot, + earliest_permissible_slot: Slot, + }, + /// We have already observed a valid payload attestation message from this validator + /// for this slot. + /// + /// ## Peer scoring + /// + /// The peer is not necessarily faulty. + PriorPayloadAttestationMessageKnown { validator_index: u64, slot: Slot }, + /// The beacon block referenced by the payload attestation message is not known. + /// + /// ## Peer scoring + /// + /// The attestation points to a block we have not yet imported. It's unclear if the + /// attestation is valid or not. + UnknownHeadBlock { beacon_block_root: Hash256 }, + /// The validator index is not a member of the PTC for this slot. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + NotInPTC { validator_index: u64, slot: Slot }, + /// The validator index is unknown. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + UnknownValidatorIndex(u64), + /// The signature on the payload attestation message is invalid. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + InvalidSignature, + /// There was an error whilst processing the payload attestation message. It is not known + /// if it is valid or invalid. + /// + /// ## Peer scoring + /// + /// We were unable to process this message due to an internal error. It's unclear if the + /// message is valid. + BeaconChainError(Box<BeaconChainError>), + /// An error reading beacon state. + /// + /// ## Peer scoring + /// + /// We were unable to process this message due to an internal error. + BeaconStateError(BeaconStateError), +} + +impl From<BeaconChainError> for Error { + fn from(e: BeaconChainError) -> Self { + Error::BeaconChainError(Box::new(e)) + } +} + +impl From<BeaconStateError> for Error { + fn from(e: BeaconStateError) -> Self { + Error::BeaconStateError(e) + } +} + +#[cfg(test)] +mod tests; diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs new file mode 100644 index 0000000000..7faad98e55 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -0,0 +1,422 @@ +use std::sync::Arc; +use std::time::Duration; + +use bls::{Keypair, Signature}; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use parking_lot::RwLock; +use proto_array::PayloadStatus; +use slot_clock::{SlotClock, TestingSlotClock}; +use state_processing::AllCaches; +use state_processing::genesis::genesis_block; +use store::{HotColdDB, StoreConfig}; +use types::{ + ChainSpec, Checkpoint, Domain, Epoch, EthSpec, Hash256, MinimalEthSpec, PayloadAttestationData, + PayloadAttestationMessage, SignedBeaconBlock, SignedRoot, Slot, +}; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + observed_attesters::ObservedPayloadAttesters, + payload_attestation_verification::{ + Error as PayloadAttestationError, + gossip_verified_payload_attestation::{ + GossipVerificationContext, VerifiedPayloadAttestationMessage, + }, + }, + test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, + validator_pubkey_cache::ValidatorPubkeyCache, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType<E>; + +const NUM_VALIDATORS: usize = 64; + +struct TestContext { + canonical_head: CanonicalHead<T>, + observed_payload_attesters: RwLock<ObservedPayloadAttesters<E>>, + validator_pubkey_cache: RwLock<ValidatorPubkeyCache<T>>, + slot_clock: TestingSlotClock, + keypairs: Vec<Keypair>, + spec: ChainSpec, + genesis_block_root: Hash256, + store: Arc<store::HotColdDB<E, store::MemoryStore<E>, store::MemoryStore<E>>>, +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::<E>(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::<E>(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let mut block = genesis_block(&state, &spec).expect("should build genesis block"); + *block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + // Advance past genesis so `now_with_past_tolerance` doesn't underflow. + slot_clock.set_current_time(spec.get_slot_duration()); + + let validator_pubkey_cache = + ValidatorPubkeyCache::new(&state, store.clone()).expect("should create pubkey cache"); + + Self { + canonical_head, + observed_payload_attesters: RwLock::new(ObservedPayloadAttesters::default()), + validator_pubkey_cache: RwLock::new(validator_pubkey_cache), + slot_clock, + keypairs, + spec, + genesis_block_root: block_root, + store, + } + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + slot_clock: &self.slot_clock, + spec: &self.spec, + observed_payload_attesters: &self.observed_payload_attesters, + canonical_head: &self.canonical_head, + validator_pubkey_cache: &self.validator_pubkey_cache, + store: &self.store, + } + } + + fn ptc_members(&self, slot: Slot) -> Vec<usize> { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let ptc = state.get_ptc(slot, &self.spec).expect("should get PTC"); + ptc.0.to_vec() + } + + fn sign_payload_attestation( + &self, + data: PayloadAttestationData, + validator_index: u64, + ) -> PayloadAttestationMessage { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let domain = self.spec.get_domain( + data.slot.epoch(E::slots_per_epoch()), + Domain::PTCAttester, + &state.fork(), + state.genesis_validators_root(), + ); + let message = data.signing_root(domain); + let signature = self.keypairs[validator_index as usize].sk.sign(message); + PayloadAttestationMessage { + validator_index, + data, + signature, + } + } +} + +fn make_payload_attestation( + slot: Slot, + validator_index: u64, + beacon_block_root: Hash256, +) -> PayloadAttestationMessage { + PayloadAttestationMessage { + validator_index, + data: PayloadAttestationData { + beacon_block_root, + slot, + payload_present: true, + blob_data_available: true, + }, + signature: Signature::empty(), + } +} + +#[test] +fn future_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let future_slot = Slot::new(5); + let msg = make_payload_attestation(future_slot, 0, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::FutureSlot { .. }) + )); +} + +#[test] +fn past_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + ctx.slot_clock.set_slot(5); + let gossip = ctx.gossip_ctx(); + + let msg = make_payload_attestation(Slot::new(0), 0, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::PastSlot { .. }) + )); +} + +#[test] +fn unknown_head_block() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let unknown_root = Hash256::repeat_byte(0xff); + let msg = make_payload_attestation(Slot::new(1), 0, unknown_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!( + matches!( + result, + Err(PayloadAttestationError::UnknownHeadBlock { .. }) + ), + "expected UnknownHeadBlock, got: {:?}", + result + ); +} + +#[test] +fn not_in_ptc() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let non_ptc_validator = (0..NUM_VALIDATORS as u64) + .find(|&i| !ptc_members.contains(&(i as usize))) + .expect("should find non-PTC validator"); + + let msg = make_payload_attestation(slot, non_ptc_validator, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::NotInPTC { .. }) + )); +} + +#[test] +fn invalid_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let validator_index = ptc_members[0] as u64; + + let msg = make_payload_attestation(slot, validator_index, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::InvalidSignature) + )); +} + +#[test] +fn valid_payload_attestation() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let validator_index = ptc_members[0] as u64; + + let data = PayloadAttestationData { + beacon_block_root: ctx.genesis_block_root, + slot, + payload_present: true, + blob_data_available: true, + }; + let msg = ctx.sign_payload_attestation(data, validator_index); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn duplicate_after_valid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let validator_index = ptc_members[0] as u64; + + let data = PayloadAttestationData { + beacon_block_root: ctx.genesis_block_root, + slot, + payload_present: true, + blob_data_available: true, + }; + + let msg1 = ctx.sign_payload_attestation(data.clone(), validator_index); + let result1 = VerifiedPayloadAttestationMessage::new(msg1, &gossip); + assert!( + result1.is_ok(), + "first message should pass: {:?}", + result1.unwrap_err() + ); + + let msg2 = ctx.sign_payload_attestation(data, validator_index); + let result2 = VerifiedPayloadAttestationMessage::new(msg2, &gossip); + assert!(matches!( + result2, + Err(PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. }) + )); +} + +/// Exercises the `partial_state_advance` fallback in gossip verification when +/// the head state is too stale to compute PTC membership (e.g., during a +/// network liveness failure with many missed slots). +#[tokio::test] +async fn stale_head_with_partial_advance() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let slots_per_epoch = E::slots_per_epoch(); + // Head at epoch 1, message at epoch 5 — 4 epochs of missed slots. + // This exceeds min_seed_lookahead (1), triggering the fallback path: + // get_advanced_hot_state loads the stored state, then partial_state_advance + // advances it through epoch boundaries to populate ptc_window. + let head_slot = Slot::new(slots_per_epoch); + let missed_epochs = 4; + let target_slot = Slot::new(slots_per_epoch * (1 + missed_epochs)); + let target_epoch = target_slot.epoch(slots_per_epoch); + + // GIVEN a chain with blocks through epoch 1 (so the store has states). + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + harness.extend_to_slot(head_slot).await; + + let head = harness.chain.canonical_head.cached_head(); + let head_epoch = head.snapshot.beacon_state.current_epoch(); + assert!( + target_epoch > head_epoch + harness.spec.min_seed_lookahead, + "precondition: message epoch must exceed head + min_seed_lookahead to trigger fallback" + ); + + // GIVEN a slot clock advanced to epoch 5 without producing blocks + // (simulating missed slots during a liveness failure). + harness.chain.slot_clock.set_slot(target_slot.as_u64()); + + // Advance a reference state to compute the PTC at the target slot. + let mut reference_state = head.snapshot.beacon_state.clone(); + state_processing::state_advance::partial_state_advance( + &mut reference_state, + Some(head.snapshot.beacon_state_root()), + target_slot, + &harness.spec, + ) + .expect("should advance reference state"); + reference_state + .build_all_caches(&harness.spec) + .expect("should build caches"); + + let ptc = reference_state + .get_ptc(target_slot, &harness.spec) + .expect("should get PTC from reference state"); + let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; + + // WHEN a properly-signed payload attestation from a PTC member is verified. + let domain = harness.spec.get_domain( + target_epoch, + Domain::PTCAttester, + &reference_state.fork(), + reference_state.genesis_validators_root(), + ); + let data = PayloadAttestationData { + beacon_block_root: head.head_block_root(), + slot: target_slot, + payload_present: true, + blob_data_available: true, + }; + let message = data.signing_root(domain); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(message); + let msg = PayloadAttestationMessage { + validator_index, + data, + signature, + }; + + // THEN verification succeeds despite the head being 4 epochs stale. + let result = harness + .chain + .verify_payload_attestation_message_for_gossip(msg); + assert!( + result.is_ok(), + "expected Ok (head epoch {}, message epoch {}), got: {:?}", + head_epoch, + target_epoch, + result.unwrap_err() + ); +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs new file mode 100644 index 0000000000..1f3f074598 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -0,0 +1,394 @@ +use std::sync::Arc; + +use crate::{ + BeaconChain, BeaconChainTypes, CanonicalHead, + payload_bid_verification::{PayloadBidError, payload_bid_cache::GossipVerifiedPayloadBidCache}, + proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache, +}; +use educe::Educe; +use eth2::types::{EventKind, ForkVersionedResponse}; +use slot_clock::SlotClock; +use state_processing::signature_sets::{ + execution_payload_bid_signature_set, get_builder_pubkey_from_state, +}; +use tracing::debug; +use types::{ + BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, + SignedProposerPreferences, Slot, +}; + +/// Verify that an execution payload bid is consistent with the current chain state +/// and proposer preferences. +pub(crate) fn verify_bid_consistency<E: EthSpec>( + bid: &ExecutionPayloadBid<E>, + current_slot: Slot, + proposer_preferences: &SignedProposerPreferences, + head_state: &BeaconState<E>, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { + let bid_slot = bid.slot; + + if bid_slot != current_slot && bid_slot != current_slot.saturating_add(1u64) { + return Err(PayloadBidError::InvalidBidSlot { bid_slot }); + } + + // Execution payments are used by off protocol builders. In protocol bids + // should always have this value set to zero. + if bid.execution_payment != 0 { + return Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: bid.execution_payment, + }); + } + + if bid.fee_recipient != proposer_preferences.message.fee_recipient { + return Err(PayloadBidError::InvalidFeeRecipient); + } + if bid.gas_limit != proposer_preferences.message.gas_limit { + return Err(PayloadBidError::InvalidGasLimit); + } + + let max_blobs_per_block = + spec.max_blobs_per_block(bid_slot.epoch(E::slots_per_epoch())) as usize; + + if bid.blob_kzg_commitments.len() > max_blobs_per_block { + return Err(PayloadBidError::InvalidBlobKzgCommitments { + max_blobs_per_block, + blob_kzg_commitments_len: bid.blob_kzg_commitments.len(), + }); + } + + let builder_index = bid.builder_index; + + let is_active_builder = head_state + .is_active_builder(builder_index, spec) + .map_err(|_| PayloadBidError::InvalidBuilder { builder_index })?; + + if !is_active_builder { + return Err(PayloadBidError::InvalidBuilder { builder_index }); + } + + if !head_state.can_builder_cover_bid(builder_index, bid.value, spec)? { + return Err(PayloadBidError::BuilderCantCoverBid { + builder_index, + builder_bid: bid.value, + }); + } + + Ok(()) +} + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead<T>, + pub gossip_verified_payload_bid_cache: &'a GossipVerifiedPayloadBidCache<T>, + pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +/// A wrapper around a `SignedExecutionPayloadBid` that indicates it has been approved for re-gossiping on +/// the p2p network. +#[derive(Educe)] +#[educe( + Debug(bound = "T: BeaconChainTypes"), + Clone(bound = "T: BeaconChainTypes") +)] +pub struct GossipVerifiedPayloadBid<T: BeaconChainTypes> { + pub signed_bid: Arc<SignedExecutionPayloadBid<T::EthSpec>>, +} + +impl<T: BeaconChainTypes> GossipVerifiedPayloadBid<T> { + pub fn new( + signed_bid: Arc<SignedExecutionPayloadBid<T::EthSpec>>, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result<Self, PayloadBidError> { + let bid_slot = signed_bid.message.slot; + let bid_parent_block_hash = signed_bid.message.parent_block_hash; + let bid_parent_block_root = signed_bid.message.parent_block_root; + let bid_value = signed_bid.message.value; + + if ctx + .gossip_verified_payload_bid_cache + .seen_builder_index(&bid_slot, signed_bid.message.builder_index) + { + return Err(PayloadBidError::BuilderAlreadySeen { + builder_index: signed_bid.message.builder_index, + slot: bid_slot, + }); + } + + // TODO(gloas): Extract into `bid_value_over_threshold` on the bid cache and potentially + // make this more sophisticate than just a <= check. + if let Some(cached_bid) = ctx.gossip_verified_payload_bid_cache.get_highest_bid( + bid_slot, + bid_parent_block_hash, + bid_parent_block_root, + ) && bid_value <= cached_bid.message.value + { + return Err(PayloadBidError::BidValueBelowCached { + cached_value: cached_bid.message.value, + incoming_value: bid_value, + }); + } + + let cached_head = ctx.canonical_head.cached_head(); + let current_slot = ctx + .slot_clock + .now() + .ok_or(PayloadBidError::UnableToReadSlot)?; + let head_state = &cached_head.snapshot.beacon_state; + + let Some(proposer_preferences) = ctx + .gossip_verified_proposer_preferences_cache + .get_preferences(&bid_slot) + else { + return Err(PayloadBidError::NoProposerPreferences { slot: bid_slot }); + }; + + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + + // TODO(gloas) reprocess bids whose parent_block_root becomes known & canonical after a reorg? + if !fork_choice.contains_block(&bid_parent_block_root) { + return Err(PayloadBidError::ParentBlockRootUnknown { + parent_block_root: bid_parent_block_root, + }); + } + + // TODO(gloas) reprocess bids whose parent_block_root becomes canonical after a reorg. + let head_root = cached_head.head_block_root(); + if !fork_choice.is_descendant(bid_parent_block_root, head_root) { + return Err(PayloadBidError::ParentBlockRootNotCanonical { + parent_block_root: bid_parent_block_root, + }); + } + + // TODO(gloas) [IGNORE] bid.parent_block_hash is the block hash of a known execution payload in fork choice. + + drop(fork_choice); + + verify_bid_consistency( + &signed_bid.message, + current_slot, + &proposer_preferences, + head_state, + ctx.spec, + )?; + + // Verify signature + execution_payload_bid_signature_set( + head_state, + |i| get_builder_pubkey_from_state(head_state, i), + &signed_bid, + ctx.spec, + ) + .map_err(|_| PayloadBidError::BadSignature)? + .ok_or(PayloadBidError::BadSignature)? + .verify() + .then_some(()) + .ok_or(PayloadBidError::BadSignature)?; + + let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; + + ctx.gossip_verified_payload_bid_cache + .insert_seen_builder(&gossip_verified_bid); + + ctx.gossip_verified_payload_bid_cache + .insert_highest_bid(gossip_verified_bid.clone()); + + Ok(gossip_verified_bid) + } +} + +impl<T: BeaconChainTypes> BeaconChain<T> { + /// Build a `GossipVerificationContext` from this `BeaconChain` for `GossipVerifiedPayloadBid`. + pub fn payload_bid_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_payload_bid_cache: &self.gossip_verified_payload_bid_cache, + gossip_verified_proposer_preferences_cache: &self + .gossip_verified_proposer_preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + /// Returns `Ok(GossipVerifiedPayloadBid)` if the supplied `bid` should be forwarded onto the + /// gossip network and cached. + /// + /// ## Errors + /// + /// Returns an `Err` if the given bid was invalid, or an error was encountered during verification. + pub fn verify_payload_bid_for_gossip( + &self, + bid: Arc<SignedExecutionPayloadBid<T::EthSpec>>, + ) -> Result<GossipVerifiedPayloadBid<T>, PayloadBidError> { + let slot = bid.message.slot; + let parent_block_root = bid.message.parent_block_root; + let parent_block_hash = bid.message.parent_block_hash; + + let ctx = self.payload_bid_gossip_verification_context(); + match GossipVerifiedPayloadBid::new(bid, &ctx) { + Ok(verified) => { + debug!( + %slot, + %parent_block_hash, + %parent_block_root, + "Successfully verified gossip payload bid" + ); + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_execution_payload_bid_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadBid(Box::new( + ForkVersionedResponse { + version: self.spec.fork_name_at_slot::<T::EthSpec>(slot), + metadata: Default::default(), + data: (*verified.signed_bid).clone(), + }, + ))); + } + + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + %slot, + %parent_block_hash, + %parent_block_root, + "Rejected gossip payload bid" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use bls::Signature; + use kzg::KzgCommitment; + use ssz_types::VariableList; + use types::{ + Address, BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, MinimalEthSpec, + ProposerPreferences, SignedProposerPreferences, Slot, + }; + + use super::verify_bid_consistency; + use crate::payload_bid_verification::PayloadBidError; + + type E = MinimalEthSpec; + + fn make_bid(slot: Slot, fee_recipient: Address, gas_limit: u64) -> ExecutionPayloadBid<E> { + ExecutionPayloadBid { + slot, + fee_recipient, + gas_limit, + value: 100, + ..ExecutionPayloadBid::default() + } + } + + fn make_preferences(fee_recipient: Address, gas_limit: u64) -> SignedProposerPreferences { + SignedProposerPreferences { + message: ProposerPreferences { + fee_recipient, + gas_limit, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + } + } + + fn state_and_spec() -> (BeaconState<E>, ChainSpec) { + let spec = E::default_spec(); + let state = BeaconState::new(0, <_>::default(), &spec); + (state, spec) + } + + #[test] + fn test_invalid_bid_slot_too_old() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(Slot::new(5), Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::<E>(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn test_invalid_bid_slot_too_far_ahead() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(Slot::new(12), Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::<E>(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn test_execution_payment_nonzero() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); + bid.execution_payment = 42; + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::<E>(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: 42 + }) + )); + } + + #[test] + fn test_fee_recipient_mismatch() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::repeat_byte(0xaa), 30_000_000); + + let result = verify_bid_consistency::<E>(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!(result, Err(PayloadBidError::InvalidFeeRecipient))); + } + + #[test] + fn test_invalid_blob_kzg_commitments() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let max_blobs = spec.max_blobs_per_block(current_slot.epoch(E::slots_per_epoch())) as usize; + let commitments: Vec<KzgCommitment> = (0..=max_blobs) + .map(|_| KzgCommitment::empty_for_testing()) + .collect(); + bid.blob_kzg_commitments = VariableList::new(commitments).unwrap(); + + let result = verify_bid_consistency::<E>(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) + )); + } + + #[test] + fn test_gas_limit_mismatch() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 50_000_000); + + let result = verify_bid_consistency::<E>(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs new file mode 100644 index 0000000000..514695f5c0 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -0,0 +1,76 @@ +//! Gossip verification for execution payload bids. +//! +//! A `SignedExecutionPayloadBid` is verified and wrapped as a `GossipVerifiedPayloadBid`, +//! which is then inserted into the `GossipVerifiedPayloadBidCache`. +//! +//! ```ignore +//! SignedExecutionPayloadBid +//! | +//! ▼ +//! GossipVerifiedPayloadBid -------> Insert into GossipVerifiedPayloadBidCache +//! ``` + +use types::{BeaconStateError, Hash256, Slot}; + +pub mod gossip_verified_bid; +pub mod payload_bid_cache; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub enum PayloadBidError { + /// The bid's parent block root is unknown. + ParentBlockRootUnknown { parent_block_root: Hash256 }, + /// The bid's parent block root is known but not on the canonical chain. + ParentBlockRootNotCanonical { parent_block_root: Hash256 }, + /// The signature is invalid. + BadSignature, + /// A bid for this builder at this slot has already been seen. + BuilderAlreadySeen { builder_index: u64, slot: Slot }, + /// Builder is not valid/active for the given epoch + InvalidBuilder { builder_index: u64 }, + /// The bid value is lower than the currently cached bid. + BidValueBelowCached { + cached_value: u64, + incoming_value: u64, + }, + /// The bids slot is not the current slot or the next slot. + InvalidBidSlot { bid_slot: Slot }, + /// The slot clock cannot be read. + UnableToReadSlot, + /// No proposer preferences for the current slot. + NoProposerPreferences { slot: Slot }, + /// The builder doesn't have enough deposited funds to cover the bid. + BuilderCantCoverBid { + builder_index: u64, + builder_bid: u64, + }, + /// The bids fee recipient doesn't match the proposer preferences fee recipient. + InvalidFeeRecipient, + /// The bids gas limit doesn't match the proposer preferences gas limit. + InvalidGasLimit, + /// The bids execution payment is non-zero + ExecutionPaymentNonZero { execution_payment: u64 }, + /// The number of blob KZG commitments exceeds the maximum allowed. + InvalidBlobKzgCommitments { + max_blobs_per_block: usize, + blob_kzg_commitments_len: usize, + }, + /// Some Beacon State error + BeaconStateError(BeaconStateError), + /// Internal error + InternalError(String), +} + +impl std::fmt::Display for PayloadBidError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From<BeaconStateError> for PayloadBidError { + fn from(e: BeaconStateError) -> Self { + PayloadBidError::BeaconStateError(e) + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs new file mode 100644 index 0000000000..1c98569bc5 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs @@ -0,0 +1,156 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +use crate::{ + BeaconChainTypes, payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, +}; +use parking_lot::RwLock; +use types::{BuilderIndex, ExecutionBlockHash, Hash256, SignedExecutionPayloadBid, Slot}; + +type HighestBidMap<T> = + BTreeMap<Slot, HashMap<(ExecutionBlockHash, Hash256), GossipVerifiedPayloadBid<T>>>; + +pub struct GossipVerifiedPayloadBidCache<T: BeaconChainTypes> { + highest_bid: RwLock<HighestBidMap<T>>, + seen_builder: RwLock<BTreeMap<Slot, HashSet<BuilderIndex>>>, +} + +impl<T: BeaconChainTypes> Default for GossipVerifiedPayloadBidCache<T> { + fn default() -> Self { + Self { + highest_bid: RwLock::new(BTreeMap::new()), + seen_builder: RwLock::new(BTreeMap::new()), + } + } +} + +impl<T: BeaconChainTypes> GossipVerifiedPayloadBidCache<T> { + /// Get the cached bid for the tuple `(slot, parent_block_hash, parent_block_root)`. + pub fn get_highest_bid( + &self, + slot: Slot, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + ) -> Option<Arc<SignedExecutionPayloadBid<T::EthSpec>>> { + self.highest_bid.read().get(&slot).and_then(|map| { + map.get(&(parent_block_hash, parent_block_root)) + .map(|b| b.signed_bid.clone()) + }) + } + + /// Insert a bid for the tuple `(slot, parent_block_hash, parent_block_root)` only if + /// its value is higher than the currently cached bid for that tuple. + pub fn insert_highest_bid(&self, bid: GossipVerifiedPayloadBid<T>) { + let key = ( + bid.signed_bid.message.parent_block_hash, + bid.signed_bid.message.parent_block_root, + ); + let mut highest_bid = self.highest_bid.write(); + let slot_map = highest_bid.entry(bid.signed_bid.message.slot).or_default(); + + if let Some(existing) = slot_map.get(&key) + && existing.signed_bid.message.value >= bid.signed_bid.message.value + { + return; + } + slot_map.insert(key, bid); + } + + /// A gossip verified bid for `BuilderIndex` already exists at `slot` + pub fn seen_builder_index(&self, slot: &Slot, builder_index: BuilderIndex) -> bool { + self.seen_builder + .read() + .get(slot) + .is_some_and(|seen_builders| seen_builders.contains(&builder_index)) + } + + /// Insert a builder into the seen cache. + pub fn insert_seen_builder(&self, bid: &GossipVerifiedPayloadBid<T>) { + let mut seen_builder = self.seen_builder.write(); + seen_builder + .entry(bid.signed_bid.message.slot) + .or_default() + .insert(bid.signed_bid.message.builder_index); + } + + /// Prune anything before `current_slot` + pub fn prune(&self, current_slot: Slot) { + self.highest_bid + .write() + .retain(|&slot, _| slot >= current_slot); + + self.seen_builder + .write() + .retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bls::Signature; + use types::{ + ExecutionBlockHash, ExecutionPayloadBid, Hash256, MinimalEthSpec, + SignedExecutionPayloadBid, Slot, + }; + + use super::GossipVerifiedPayloadBidCache; + use crate::{ + payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, + test_utils::EphemeralHarnessType, + }; + + type E = MinimalEthSpec; + type T = EphemeralHarnessType<E>; + + fn make_gossip_verified( + slot: Slot, + builder_index: u64, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + value: u64, + ) -> GossipVerifiedPayloadBid<T> { + GossipVerifiedPayloadBid { + signed_bid: Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index, + parent_block_hash, + parent_block_root, + value, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }), + } + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedPayloadBidCache::<T>::default(); + let hash = ExecutionBlockHash::zero(); + let root = Hash256::ZERO; + + for slot in [1, 2, 3, 7, 8, 9, 10] { + let verified = make_gossip_verified(Slot::new(slot), slot, hash, root, slot * 100); + cache.insert_seen_builder(&verified); + cache.insert_highest_bid(verified); + } + + cache.prune(Slot::new(8)); + + // Slots 1-7 pruned from both maps. + for slot in [1, 2, 3, 7] { + assert!(cache.get_highest_bid(Slot::new(slot), hash, root).is_none()); + assert!(!cache.seen_builder_index(&Slot::new(slot), slot)); + } + // Slots 8-10 retained in both maps. + for slot in [8, 9, 10] { + assert!(cache.get_highest_bid(Slot::new(slot), hash, root).is_some()); + assert!(cache.seen_builder_index(&Slot::new(slot), slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs new file mode 100644 index 0000000000..b7b77d5d2a --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -0,0 +1,750 @@ +use std::sync::Arc; + +use std::time::Duration; + +use bls::{Keypair, PublicKeyBytes, Signature}; +use ethereum_hashing::hash; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use kzg::KzgCommitment; +use slot_clock::{SlotClock, TestingSlotClock}; +use ssz::Encode; +use ssz_types::VariableList; +use state_processing::genesis::genesis_block; +use store::{HotColdDB, StoreConfig}; +use types::{ + Address, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, + ExecutionPayloadBid, Hash256, MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, + SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, +}; + +use proto_array::{Block as ProtoBlock, ExecutionStatus, PayloadStatus}; +use types::AttestationShufflingId; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + payload_bid_verification::{ + PayloadBidError, + gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, + payload_bid_cache::GossipVerifiedPayloadBidCache, + }, + proposer_preferences_verification::{ + gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences, + proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, + test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType<E>; + +/// Number of regular validators (must be >= min_genesis_active_validator_count for MinimalEthSpec). +const NUM_VALIDATORS: usize = 64; +/// Number of builders to register. +const NUM_BUILDERS: usize = 4; +/// Balance given to each builder (min_deposit_amount + extra to cover bids in tests). +const BUILDER_BALANCE: u64 = 2_000_000_000; + +struct TestContext { + canonical_head: CanonicalHead<T>, + bid_cache: GossipVerifiedPayloadBidCache<T>, + preferences_cache: GossipVerifiedProposerPreferenceCache, + slot_clock: TestingSlotClock, + keypairs: Vec<Keypair>, + spec: ChainSpec, + genesis_block_root: Hash256, + inactive_builder_index: u64, +} + +fn builder_withdrawal_credentials(pubkey: &bls::PublicKey, spec: &ChainSpec) -> Hash256 { + let fake_execution_address = &hash(&pubkey.as_ssz_bytes())[0..20]; + let mut credentials = [0u8; 32]; + credentials[0] = spec.builder_withdrawal_prefix_byte; + credentials[12..].copy_from_slice(fake_execution_address); + Hash256::from_slice(&credentials) +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::<E>(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::<E>(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + // Register builders in the builder registry. + for keypair in keypairs.iter().take(NUM_BUILDERS) { + let creds = builder_withdrawal_credentials(&keypair.pk, &spec); + state + .add_builder_to_registry( + PublicKeyBytes::from(keypair.pk.clone()), + creds, + BUILDER_BALANCE, + Slot::new(0), + &spec, + ) + .expect("should register builder"); + } + + // Bump finalized checkpoint epoch so builders are considered active + // (is_active_builder requires deposit_epoch < finalized_checkpoint.epoch). + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let inactive_keypair = &keypairs[NUM_BUILDERS]; + let inactive_creds = builder_withdrawal_credentials(&inactive_keypair.pk, &spec); + let inactive_builder_index = state + .add_builder_to_registry( + PublicKeyBytes::from(inactive_keypair.pk.clone()), + inactive_creds, + BUILDER_BALANCE, + Slot::new(E::slots_per_epoch()), + &spec, + ) + .expect("should register inactive builder"); + + let mut block = genesis_block(&state, &spec).expect("should build genesis block"); + *block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + + Self { + canonical_head, + bid_cache: GossipVerifiedPayloadBidCache::default(), + preferences_cache: GossipVerifiedProposerPreferenceCache::default(), + slot_clock, + keypairs, + spec, + genesis_block_root: block_root, + inactive_builder_index, + } + } + + fn sign_bid(&self, bid: ExecutionPayloadBid<E>) -> Arc<SignedExecutionPayloadBid<E>> { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let domain = self.spec.get_domain( + bid.slot.epoch(E::slots_per_epoch()), + Domain::BeaconBuilder, + &state.fork(), + state.genesis_validators_root(), + ); + let message = bid.signing_root(domain); + let signature = self.keypairs[bid.builder_index as usize].sk.sign(message); + Arc::new(SignedExecutionPayloadBid { + message: bid, + signature, + }) + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_payload_bid_cache: &self.bid_cache, + gossip_verified_proposer_preferences_cache: &self.preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + fn insert_non_canonical_block(&self) -> Hash256 { + let shuffling_id = AttestationShufflingId { + shuffling_epoch: Epoch::new(0), + shuffling_decision_block: self.genesis_block_root, + }; + let fork_block_root = Hash256::repeat_byte(0xab); + let mut fc = self.canonical_head.fork_choice_write_lock(); + fc.proto_array_mut() + .process_block::<E>( + ProtoBlock { + slot: Slot::new(1), + root: fork_block_root, + parent_root: Some(self.genesis_block_root), + target_root: fork_block_root, + current_epoch_shuffling_id: shuffling_id.clone(), + next_epoch_shuffling_id: shuffling_id, + state_root: Hash256::ZERO, + justified_checkpoint: Checkpoint { + epoch: Epoch::new(0), + root: self.genesis_block_root, + }, + finalized_checkpoint: Checkpoint { + epoch: Epoch::new(0), + root: self.genesis_block_root, + }, + execution_status: ExecutionStatus::irrelevant(), + unrealized_justified_checkpoint: None, + unrealized_finalized_checkpoint: None, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), + proposer_index: Some(0), + }, + Slot::new(1), + &self.spec, + Duration::from_secs(0), + ) + .expect("should insert fork block"); + fork_block_root + } +} + +fn make_signed_bid( + slot: Slot, + builder_index: u64, + fee_recipient: Address, + gas_limit: u64, + value: u64, + parent_block_root: Hash256, +) -> Arc<SignedExecutionPayloadBid<E>> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index, + fee_recipient, + gas_limit, + value, + parent_block_root, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }) +} + +fn make_signed_preferences( + proposal_slot: Slot, + validator_index: u64, + fee_recipient: Address, + gas_limit: u64, +) -> Arc<SignedProposerPreferences> { + Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient, + gas_limit, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + }) +} + +fn seed_preferences(ctx: &TestContext, slot: Slot, fee_recipient: Address, gas_limit: u64) { + let prefs = GossipVerifiedProposerPreferences { + signed_preferences: make_signed_preferences(slot, 0, fee_recipient, gas_limit), + }; + ctx.preferences_cache.insert_preferences(prefs); +} + +#[test] +fn no_proposer_preferences_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let bid = make_signed_bid( + Slot::new(0), + 0, + Address::ZERO, + 30_000_000, + 100, + Hash256::ZERO, + ); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::NoProposerPreferences { .. }) + )); +} + +#[test] +fn builder_already_seen_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid(slot, 42, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let verified = GossipVerifiedPayloadBid { + signed_bid: bid.clone(), + }; + ctx.bid_cache.insert_seen_builder(&verified); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BuilderAlreadySeen { + builder_index: 42, + .. + }) + )); +} + +#[test] +fn bid_value_below_cached() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let high_bid = GossipVerifiedPayloadBid { + signed_bid: make_signed_bid(slot, 99, Address::ZERO, 30_000_000, 500, Hash256::ZERO), + }; + ctx.bid_cache.insert_highest_bid(high_bid); + + let low_bid = make_signed_bid(slot, 1, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let result = GossipVerifiedPayloadBid::new(low_bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BidValueBelowCached { .. }) + )); +} + +#[test] +fn invalid_bid_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(5); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); +} + +#[test] +fn fee_recipient_mismatch() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::repeat_byte(0xaa), 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidFeeRecipient))); +} + +#[test] +fn gas_limit_mismatch() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 50_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); +} + +#[test] +fn execution_payment_nonzero() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + gas_limit: 30_000_000, + execution_payment: 42, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::ExecutionPaymentNonZero { .. }) + )); +} + +#[test] +fn unknown_builder_index() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Use a builder_index that doesn't exist in the registry. + let bid = make_signed_bid( + slot, + 9999, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBuilder { + builder_index: 9999 + }) + )); +} + +#[test] +fn inactive_builder() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + ctx.inactive_builder_index, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBuilder { .. }) + )); +} + +#[test] +fn builder_cant_cover_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Builder index 0 exists but bid value far exceeds their balance. + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + u64::MAX, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BuilderCantCoverBid { .. }) + )); +} + +#[test] +fn parent_block_root_unknown() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Parent block root not in fork choice. + let unknown_root = Hash256::repeat_byte(0xff); + let bid = make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 0, unknown_root); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(result.is_err(), "expected error, got Ok"); + let err = result.unwrap_err(); + assert!( + matches!(err, PayloadBidError::ParentBlockRootUnknown { .. }), + "expected ParentBlockRootUnknown, got: {err:?}" + ); +} + +#[test] +fn parent_block_root_not_canonical() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let fork_root = ctx.insert_non_canonical_block(); + let bid = make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 0, fork_root); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(result.is_err(), "expected error, got Ok"); + let err = result.unwrap_err(); + assert!( + matches!(err, PayloadBidError::ParentBlockRootNotCanonical { .. }), + "expected ParentBlockRootNotCanonical, got: {err:?}" + ); +} + +#[test] +fn invalid_blob_kzg_commitments() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let max_blobs = ctx + .spec + .max_blobs_per_block(slot.epoch(E::slots_per_epoch())) as usize; + let commitments: Vec<KzgCommitment> = (0..=max_blobs) + .map(|_| KzgCommitment::empty_for_testing()) + .collect(); + + let bid = Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + blob_kzg_commitments: VariableList::new(commitments).unwrap(), + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) + )); +} + +#[test] +fn bad_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // All checks pass but signature is empty/invalid. + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::BadSignature))); + assert!(!ctx.bid_cache.seen_builder_index(&slot, 0)); + assert!( + ctx.bid_cache + .get_highest_bid(slot, ExecutionBlockHash::zero(), ctx.genesis_block_root) + .is_none() + ); +} + +#[test] +fn valid_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn two_builders_coexist_in_cache() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid_0 = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result_0 = GossipVerifiedPayloadBid::new(bid_0, &gossip); + assert!( + result_0.is_ok(), + "builder 0 should pass: {:?}", + result_0.unwrap_err() + ); + + // Builder 1 must bid strictly higher than builder 0's cached value. + let bid_1 = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 1, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 1, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result_1 = GossipVerifiedPayloadBid::new(bid_1, &gossip); + assert!( + result_1.is_ok(), + "builder 1 should pass: {:?}", + result_1.unwrap_err() + ); + + // Both builders should be seen. + assert!(ctx.bid_cache.seen_builder_index(&slot, 0)); + assert!(ctx.bid_cache.seen_builder_index(&slot, 1)); + + let highest = ctx + .bid_cache + .get_highest_bid(slot, ExecutionBlockHash::zero(), ctx.genesis_block_root) + .expect("should have highest bid"); + assert_eq!(highest.message.value, 1); + assert_eq!(highest.message.builder_index, 1); +} + +#[test] +fn bid_equal_to_cached_value_rejected() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Seed a cached bid with value 100. + let high_bid = GossipVerifiedPayloadBid { + signed_bid: make_signed_bid( + slot, + 99, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ), + }; + ctx.bid_cache.insert_highest_bid(high_bid); + + // Submit a bid with exactly the same value — should be rejected. + let equal_bid = make_signed_bid( + slot, + 1, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(equal_bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BidValueBelowCached { + cached_value: 100, + incoming_value: 100, + }) + )); +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs new file mode 100644 index 0000000000..4e36cf7895 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +#[cfg(test)] +use mockall::automock; +use task_executor::TaskExecutor; +use types::{Hash256, SignedExecutionPayloadEnvelope, Slot}; + +use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; + +/// An adapter to the `BeaconChain` functionalities to remove `BeaconChain` from direct dependency to enable testing envelope streamer logic. +pub(crate) struct EnvelopeStreamerBeaconAdapter<T: BeaconChainTypes> { + chain: Arc<BeaconChain<T>>, +} + +#[cfg_attr(test, automock, allow(dead_code))] +impl<T: BeaconChainTypes> EnvelopeStreamerBeaconAdapter<T> { + pub(crate) fn new(chain: Arc<BeaconChain<T>>) -> Self { + Self { chain } + } + + pub(crate) fn executor(&self) -> &TaskExecutor { + &self.chain.task_executor + } + + pub(crate) fn get_payload_envelope( + &self, + root: &Hash256, + ) -> Result<Option<SignedExecutionPayloadEnvelope<T::EthSpec>>, store::Error> { + self.chain.store.get_payload_envelope(root) + } + + pub(crate) fn get_split_slot(&self) -> Slot { + self.chain.store.get_split_info().slot + } + + pub(crate) fn block_has_canonical_payload( + &self, + root: &Hash256, + ) -> Result<bool, BeaconChainError> { + self.chain + .canonical_head + .block_has_canonical_payload(root, &self.chain.spec) + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs new file mode 100644 index 0000000000..5b1bda5dd5 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs @@ -0,0 +1,214 @@ +mod beacon_chain_adapter; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +#[cfg_attr(test, double)] +use crate::payload_envelope_streamer::beacon_chain_adapter::EnvelopeStreamerBeaconAdapter; +use futures::Stream; +#[cfg(test)] +use mockall_double::double; +use tokio::sync::mpsc::{self, UnboundedSender}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tracing::{debug, error, warn}; +use types::{EthSpec, Hash256, SignedExecutionPayloadEnvelope}; + +#[cfg(not(test))] +use crate::BeaconChain; +use crate::{BeaconChainError, BeaconChainTypes}; + +type PayloadEnvelopeResult<E> = + Result<Option<Arc<SignedExecutionPayloadEnvelope<E>>>, BeaconChainError>; + +#[derive(Debug)] +pub enum Error { + BlockMissingFromForkChoice, +} + +#[derive(Debug, PartialEq)] +pub enum EnvelopeRequestSource { + ByRoot, + ByRange, +} + +pub struct PayloadEnvelopeStreamer<T: BeaconChainTypes> { + adapter: EnvelopeStreamerBeaconAdapter<T>, + request_source: EnvelopeRequestSource, +} + +// TODO(gloas) eventually we'll need to expand this to support loading blinded payload envelopes from the db +// and fetching the execution payload from the EL. See BlockStreamer impl as an example +impl<T: BeaconChainTypes> PayloadEnvelopeStreamer<T> { + pub(crate) fn new( + adapter: EnvelopeStreamerBeaconAdapter<T>, + request_source: EnvelopeRequestSource, + ) -> Arc<Self> { + Arc::new(Self { + adapter, + request_source, + }) + } + + // TODO(gloas) simply a stub impl for now. Should check some exec payload envelope cache + // and return the envelope if it exists in the cache + fn check_payload_envelope_cache( + &self, + _beacon_block_root: &Hash256, + ) -> Option<Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>> { + // if self.check_caches == CheckCaches::Yes + None + } + + fn load_envelope( + self: &Arc<Self>, + beacon_block_root: &Hash256, + ) -> Result<Option<Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>>, BeaconChainError> { + if let Some(cached_envelope) = self.check_payload_envelope_cache(beacon_block_root) { + Ok(Some(cached_envelope)) + } else { + // TODO(gloas) we'll want to use the execution layer directly to call + // the engine api method eth_getPayloadBodiesByRange() + match self.adapter.get_payload_envelope(beacon_block_root) { + Ok(opt_envelope) => Ok(opt_envelope.map(Arc::new)), + Err(e) => Err(BeaconChainError::DBError(e)), + } + } + } + + async fn load_envelopes( + self: &Arc<Self>, + block_roots: &[Hash256], + ) -> Result<Vec<(Hash256, PayloadEnvelopeResult<T::EthSpec>)>, BeaconChainError> { + let streamer = self.clone(); + let block_roots = block_roots.to_vec(); + let split_slot = streamer.adapter.get_split_slot(); + // Loading from the DB is slow -> spawn a blocking task + self.adapter + .executor() + .spawn_blocking_handle( + move || { + let mut results: Vec<(Hash256, PayloadEnvelopeResult<T::EthSpec>)> = Vec::new(); + for root in block_roots.iter() { + // TODO(gloas) we are loading the full envelope from the db. + // in a future PR we will only be storing the blinded envelope. + // When that happens we'll need to use the EL here to fetch + // the payload and reconstruct the non-blinded envelope. + let opt_envelope = match streamer.load_envelope(root) { + Ok(opt_envelope) => opt_envelope, + Err(e) => { + results.push((*root, Err(e))); + continue; + } + }; + + if streamer.request_source == EnvelopeRequestSource::ByRoot { + // No envelope verification required for `ENVELOPE_BY_ROOT` requests. + // If we only served envelopes that match our canonical view, nodes + // wouldn't be able to sync other branches. + results.push((*root, Ok(opt_envelope))); + continue; + } + + // When loading envelopes on or after the split slot, we must cross reference the bid from the child beacon block. + // There can be payloads that have been imported into the hot db but don't match our current view + // of the canonical chain. + + if let Some(envelope) = opt_envelope { + // Ensure that the envelopes we're serving match our view of the canonical chain. + + // When loading envelopes before the split slot, there is no need to check. + // Non-canonical payload envelopes will have already been pruned. + if split_slot > envelope.slot() { + results.push((*root, Ok(Some(envelope)))); + continue; + } + + match streamer.adapter.block_has_canonical_payload(root) { + Ok(is_envelope_canonical) => { + if is_envelope_canonical { + results.push((*root, Ok(Some(envelope)))); + } else { + results.push((*root, Ok(None))); + } + } + Err(e) => { + results.push((*root, Err(e))); + } + } + } else { + results.push((*root, Ok(None))); + } + } + results + }, + "load_execution_payload_envelopes", + ) + .ok_or(BeaconChainError::RuntimeShutdown)? + .await + .map_err(BeaconChainError::TokioJoin) + } + + async fn stream_payload_envelopes( + self: Arc<Self>, + beacon_block_roots: Vec<Hash256>, + sender: UnboundedSender<(Hash256, Arc<PayloadEnvelopeResult<T::EthSpec>>)>, + ) { + let results = match self.load_envelopes(&beacon_block_roots).await { + Ok(results) => results, + Err(e) => { + warn!(error = ?e, "Failed to load payload envelopes"); + send_errors(&beacon_block_roots, sender, e).await; + return; + } + }; + + for (root, result) in results { + if sender.send((root, Arc::new(result))).is_err() { + break; + } + } + } + + pub fn launch_stream( + self: Arc<Self>, + block_roots: Vec<Hash256>, + ) -> impl Stream<Item = (Hash256, Arc<PayloadEnvelopeResult<T::EthSpec>>)> { + let (envelope_tx, envelope_rx) = mpsc::unbounded_channel(); + debug!( + envelopes = block_roots.len(), + "Launching a PayloadEnvelopeStreamer" + ); + let executor = self.adapter.executor().clone(); + executor.spawn( + self.stream_payload_envelopes(block_roots, envelope_tx), + "get_payload_envelopes_sender", + ); + UnboundedReceiverStream::new(envelope_rx) + } +} + +/// Create a `PayloadEnvelopeStreamer` from a `BeaconChain` and launch a stream. +#[cfg(not(test))] +pub fn launch_payload_envelope_stream<T: BeaconChainTypes>( + chain: Arc<BeaconChain<T>>, + block_roots: Vec<Hash256>, + request_source: EnvelopeRequestSource, +) -> impl Stream<Item = (Hash256, Arc<PayloadEnvelopeResult<T::EthSpec>>)> { + let adapter = beacon_chain_adapter::EnvelopeStreamerBeaconAdapter::new(chain); + PayloadEnvelopeStreamer::new(adapter, request_source).launch_stream(block_roots) +} + +async fn send_errors<E: EthSpec>( + block_roots: &[Hash256], + sender: UnboundedSender<(Hash256, Arc<PayloadEnvelopeResult<E>>)>, + beacon_chain_error: BeaconChainError, +) { + let result = Arc::new(Err(beacon_chain_error)); + for beacon_block_root in block_roots { + if sender.send((*beacon_block_root, result.clone())).is_err() { + error!("EnvelopeStreamer channel closed unexpectedly"); + break; + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs new file mode 100644 index 0000000000..be763b4ee2 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -0,0 +1,385 @@ +use super::*; +use crate::beacon_chain::ForkChoiceError; +use crate::payload_envelope_streamer::beacon_chain_adapter::MockEnvelopeStreamerBeaconAdapter; +use crate::test_utils::EphemeralHarnessType; +use bls::{FixedBytesExtended, Signature}; +use futures::StreamExt; +use std::collections::HashMap; +use task_executor::test_utils::TestRuntime; +use types::{ + ExecutionBlockHash, ExecutionPayloadEnvelope, ExecutionPayloadGloas, Hash256, MinimalEthSpec, + SignedExecutionPayloadEnvelope, Slot, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType<E>; + +struct SlotEntry { + block_root: Hash256, + slot: Slot, + envelope: Option<SignedExecutionPayloadEnvelope<E>>, + non_canonical_envelope: bool, +} + +impl SlotEntry { + fn expect_envelope(&self, split_slot: Option<Slot>) -> bool { + if self.envelope.is_none() { + return false; + } + if !self.non_canonical_envelope { + return true; + } + // Non-canonical envelopes before the split slot are returned + // (in production they would have been pruned). + split_slot.is_some_and(|s| self.slot < s) + } +} + +fn roots(chain: &[SlotEntry]) -> Vec<Hash256> { + chain.iter().map(|s| s.block_root).collect() +} + +/// Build test chain data. +fn build_chain( + num_slots: u64, + skipped_slots: &[u64], + missing_envelope_slots: &[u64], + non_canonical_envelope_slots: &[u64], +) -> Vec<SlotEntry> { + let mut chain = Vec::new(); + for i in 1..=num_slots { + if skipped_slots.contains(&i) { + continue; + } + let slot = Slot::new(i); + let block_root = Hash256::from_low_u64_be(i); + let has_envelope = !missing_envelope_slots.contains(&i); + let is_non_canonical = non_canonical_envelope_slots.contains(&i); + + let envelope = if has_envelope { + let block_hash = if is_non_canonical { + ExecutionBlockHash::from_root(Hash256::repeat_byte(0xFF)) + } else { + ExecutionBlockHash::from_root(Hash256::from_low_u64_be(i)) + }; + Some(SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + block_hash, + slot_number: slot, + ..Default::default() + }, + execution_requests: Default::default(), + builder_index: 0, + beacon_block_root: block_root, + parent_beacon_block_root: Hash256::ZERO, + }, + signature: Signature::empty(), + }) + } else { + None + }; + + chain.push(SlotEntry { + block_root, + slot, + envelope, + non_canonical_envelope: is_non_canonical, + }); + } + chain +} + +fn mock_adapter() -> (MockEnvelopeStreamerBeaconAdapter<T>, TestRuntime) { + let runtime = TestRuntime::default(); + let mut mock = MockEnvelopeStreamerBeaconAdapter::default(); + mock.expect_executor() + .return_const(runtime.task_executor.clone()); + (mock, runtime) +} + +/// Configure `get_payload_envelope` to return envelopes from chain data. +fn mock_envelopes(mock: &mut MockEnvelopeStreamerBeaconAdapter<T>, chain: &[SlotEntry]) { + let envelope_map: HashMap<Hash256, Option<SignedExecutionPayloadEnvelope<E>>> = chain + .iter() + .map(|entry| (entry.block_root, entry.envelope.clone())) + .collect(); + mock.expect_get_payload_envelope() + .returning(move |root| Ok(envelope_map.get(root).cloned().flatten())); +} + +/// Configure `block_has_canonical_payload` based on chain's non-canonical entries. +fn mock_canonical_head(mock: &mut MockEnvelopeStreamerBeaconAdapter<T>, chain: &[SlotEntry]) { + let non_canonical: Vec<Hash256> = chain + .iter() + .filter(|e| e.non_canonical_envelope) + .map(|e| e.block_root) + .collect(); + mock.expect_block_has_canonical_payload() + .returning(move |root| Ok(!non_canonical.contains(root))); +} + +fn unwrap_result( + result: &Arc<PayloadEnvelopeResult<E>>, +) -> &Option<Arc<SignedExecutionPayloadEnvelope<E>>> { + result + .as_ref() + .as_ref() + .expect("unexpected error in stream result") +} + +async fn assert_stream_matches( + stream: &mut (impl Stream<Item = (Hash256, Arc<PayloadEnvelopeResult<E>>)> + Unpin), + chain: &[SlotEntry], + split_slot: Option<Slot>, +) { + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + + let result = unwrap_result(&result); + + if entry.expect_envelope(split_slot) { + let envelope = result + .as_ref() + .unwrap_or_else(|| panic!("expected Some at index {i} but got None")); + let expected_envelope = entry.envelope.as_ref().unwrap(); + assert_eq!( + envelope.block_hash(), + expected_envelope.block_hash(), + "block_hash mismatch at index {i}" + ); + } else { + assert!( + result.is_none(), + "expected None at index {i} (missing or non-canonical), got Some" + ); + } + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// Happy path: all envelopes exist and are canonical. +#[tokio::test] +async fn stream_envelopes_by_range() { + let chain = build_chain(8, &[], &[], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + assert_stream_matches(&mut stream, &chain, None).await; +} + +/// Mixed chain: skipped slots, missing envelopes, and non-canonical envelopes. +#[tokio::test] +async fn stream_envelopes_by_range_mixed() { + let chain = build_chain(12, &[3, 8], &[5], &[7, 11]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + assert_stream_matches(&mut stream, &chain, None).await; +} + +/// Non-canonical envelopes before the split slot bypass canonical verification +/// and are returned. Non-canonical envelopes after the split slot are filtered out. +#[tokio::test] +async fn stream_envelopes_by_range_before_split() { + // Non-canonical envelopes at slots 2 and 4 (before split), slot 8 (after split). + let chain = build_chain(10, &[], &[], &[2, 4, 8]); + let split_slot = Slot::new(6); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(split_slot); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + assert_stream_matches(&mut stream, &chain, Some(split_slot)).await; +} + +#[tokio::test] +async fn stream_envelopes_empty_roots() { + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(vec![]); + assert!( + stream.next().await.is_none(), + "empty roots should produce no results" + ); +} + +#[tokio::test] +async fn stream_envelopes_single_root() { + let chain = build_chain(3, &[], &[], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(vec![chain[1].block_root]); + + let (root, result) = stream.next().await.expect("should get one result"); + assert_eq!(root, chain[1].block_root); + let envelope = unwrap_result(&result) + .as_ref() + .expect("should have envelope"); + assert_eq!( + envelope.block_hash(), + chain[1].envelope.as_ref().unwrap().block_hash(), + ); + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// ByRoot requests skip canonical verification, so non-canonical envelopes +/// should still be returned. `block_has_canonical_payload` should never be called. +#[tokio::test] +async fn stream_envelopes_by_root() { + let chain = build_chain(8, &[], &[], &[3, 5, 7]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock.expect_block_has_canonical_payload().times(0); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRoot); + let mut stream = streamer.launch_stream(roots(&chain)); + + // Every envelope should come back as Some, even the non-canonical ones. + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + + let envelope = unwrap_result(&result) + .as_ref() + .unwrap_or_else(|| panic!("expected Some at index {i} for ByRoot request")); + let expected_envelope = entry.envelope.as_ref().unwrap(); + assert_eq!( + envelope.block_hash(), + expected_envelope.block_hash(), + "block_hash mismatch at index {i}" + ); + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// When `block_has_canonical_payload` returns an error, the streamer should +/// propagate that error for those roots. +#[tokio::test] +async fn stream_envelopes_error() { + let chain = build_chain(4, &[], &[], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock.expect_block_has_canonical_payload().returning(|_| { + Err(BeaconChainError::ForkChoiceError( + ForkChoiceError::DoesNotDescendFromFinalizedCheckpoint, + )) + }); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + assert!( + result.as_ref().is_err(), + "expected error at index {i}, got {:?}", + result + ); + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// Requesting unknown roots (not in the store) via ByRange should return Ok(None). +#[tokio::test] +async fn stream_envelopes_by_range_unknown_roots() { + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock.expect_get_payload_envelope().returning(|_| Ok(None)); + + let unknown_roots: Vec<Hash256> = (1..=4) + .map(|i| Hash256::from_low_u64_be(i * 1000)) + .collect(); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(unknown_roots.clone()); + + for (i, expected_root) in unknown_roots.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, *expected_root, "root mismatch at index {i}"); + let envelope = unwrap_result(&result); + assert!( + envelope.is_none(), + "expected None for unknown root at index {i}" + ); + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// Requesting roots via ByRoot where some envelopes are missing should +/// return Ok(None) for those roots. +#[tokio::test] +async fn stream_envelopes_by_root_missing_envelopes() { + let chain = build_chain(6, &[], &[2, 4], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock.expect_block_has_canonical_payload().times(0); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRoot); + let mut stream = streamer.launch_stream(roots(&chain)); + + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + + let envelope_opt = unwrap_result(&result); + if let Some(entry_envelope) = &entry.envelope { + let envelope = envelope_opt + .as_ref() + .unwrap_or_else(|| panic!("expected Some at index {i}")); + assert_eq!( + envelope.block_hash(), + entry_envelope.block_hash(), + "block_hash mismatch at index {i}" + ); + } else { + assert!( + envelope_opt.is_none(), + "expected None for missing envelope at index {i}" + ); + } + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs new file mode 100644 index 0000000000..4b8e7347cc --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use slot_clock::SlotClock; +use state_processing::{VerifySignatures, envelope_processing::verify_execution_payload_envelope}; +use types::EthSpec; + +use crate::{ + BeaconChain, BeaconChainError, BeaconChainTypes, NotifyExecutionLayer, + PayloadVerificationOutcome, + block_verification::PayloadVerificationHandle, + payload_envelope_verification::{ + EnvelopeError, EnvelopeImportData, MaybeAvailableEnvelope, + gossip_verified_envelope::GossipVerifiedEnvelope, load_snapshot_from_state_root, + payload_notifier::PayloadNotifier, + }, +}; + +pub struct ExecutionPendingEnvelope<E: EthSpec> { + pub signed_envelope: MaybeAvailableEnvelope<E>, + pub import_data: EnvelopeImportData<E>, + pub payload_verification_handle: PayloadVerificationHandle, +} + +impl<T: BeaconChainTypes> GossipVerifiedEnvelope<T> { + pub fn into_execution_pending_envelope( + self, + chain: &Arc<BeaconChain<T>>, + notify_execution_layer: NotifyExecutionLayer, + ) -> Result<ExecutionPendingEnvelope<T::EthSpec>, EnvelopeError> { + let signed_envelope = self.signed_envelope; + let envelope = &signed_envelope.message; + let payload = &envelope.payload; + + // Define a future that will verify the execution payload with an execution engine. + // + // We do this as early as possible so that later parts of this function can run in parallel + // with the payload verification. + let payload_notifier = PayloadNotifier::new( + chain.clone(), + signed_envelope.clone(), + self.block.clone(), + notify_execution_layer, + )?; + let block_root = envelope.beacon_block_root; + let slot = self.block.slot(); + + let payload_verification_future = async move { + let chain = payload_notifier.chain.clone(); + if let Some(started_execution) = chain.slot_clock.now_duration() { + chain + .envelope_times_cache + .write() + .set_time_started_execution(block_root, slot, started_execution); + } + + let payload_verification_status = payload_notifier.notify_new_payload().await?; + Ok(PayloadVerificationOutcome { + payload_verification_status, + }) + }; + // Spawn the payload verification future as a new task, but don't wait for it to complete. + // The `payload_verification_future` will be awaited later to ensure verification completed + // successfully. + let payload_verification_handle = chain + .task_executor + .spawn_handle( + payload_verification_future, + "execution_payload_verification", + ) + .ok_or(BeaconChainError::RuntimeShutdown)?; + + let snapshot = if let Some(snapshot) = self.snapshot { + *snapshot + } else { + load_snapshot_from_state_root::<T>(block_root, self.block.state_root(), &chain.store)? + }; + let state = snapshot.pre_state; + + // Verify the envelope against the state (no state mutation). + verify_execution_payload_envelope( + &state, + &signed_envelope, + // verify signature already done for GossipVerifiedEnvelope + VerifySignatures::False, + snapshot.state_root, + &chain.spec, + )?; + + Ok(ExecutionPendingEnvelope { + signed_envelope: MaybeAvailableEnvelope::AvailabilityPending { + block_hash: payload.block_hash, + envelope: signed_envelope, + }, + import_data: EnvelopeImportData { + block_root, + _phantom: Default::default(), + }, + payload_verification_handle, + }) + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs new file mode 100644 index 0000000000..a20963302b --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -0,0 +1,460 @@ +use std::sync::Arc; + +use educe::Educe; +use eth2::types::{EventKind, SseExecutionPayloadGossip}; +use parking_lot::{Mutex, RwLock}; +use store::DatabaseBlock; +use tracing::debug; +use types::{ + ChainSpec, EthSpec, ExecutionPayloadBid, ExecutionPayloadEnvelope, Hash256, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, +}; + +use crate::{ + BeaconChain, BeaconChainError, BeaconChainTypes, BeaconStore, ServerSentEventHandler, + beacon_proposer_cache::{self, BeaconProposerCache}, + canonical_head::CanonicalHead, + payload_envelope_verification::{ + EnvelopeError, EnvelopeProcessingSnapshot, load_snapshot_from_state_root, + }, + validator_pubkey_cache::ValidatorPubkeyCache, +}; + +/// Bundles only the dependencies needed for gossip verification of execution payload envelopes, +/// decoupling `GossipVerifiedEnvelope::new` from the full `BeaconChain`. +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead<T>, + pub store: &'a BeaconStore<T>, + pub spec: &'a ChainSpec, + pub beacon_proposer_cache: &'a Mutex<BeaconProposerCache>, + pub validator_pubkey_cache: &'a RwLock<ValidatorPubkeyCache<T>>, + pub genesis_validators_root: Hash256, + pub event_handler: &'a Option<ServerSentEventHandler<T::EthSpec>>, +} + +/// Verify that an execution payload envelope is consistent with its beacon block +/// and execution bid. +pub(crate) fn verify_envelope_consistency<E: EthSpec>( + envelope: &ExecutionPayloadEnvelope<E>, + block: &SignedBeaconBlock<E>, + execution_bid: &ExecutionPayloadBid<E>, + latest_finalized_slot: Slot, +) -> Result<(), EnvelopeError> { + // Check that the envelope's slot isn't from a slot prior + // to the latest finalized slot. + if envelope.slot() < latest_finalized_slot { + return Err(EnvelopeError::PriorToFinalization { + payload_slot: envelope.slot(), + latest_finalized_slot, + }); + } + + // Check that the slot of the envelope matches the slot of the block. + if envelope.slot() != block.slot() { + return Err(EnvelopeError::SlotMismatch { + block: block.slot(), + envelope: envelope.slot(), + }); + } + + // Builder index matches committed bid. + if envelope.builder_index != execution_bid.builder_index { + return Err(EnvelopeError::BuilderIndexMismatch { + committed_bid: execution_bid.builder_index, + envelope: envelope.builder_index, + }); + } + + // The block hash should match the block hash of the execution bid. + if envelope.payload.block_hash != execution_bid.block_hash { + return Err(EnvelopeError::BlockHashMismatch { + committed_bid: execution_bid.block_hash, + envelope: envelope.payload.block_hash, + }); + } + + Ok(()) +} + +/// A wrapper around a `SignedExecutionPayloadEnvelope` that indicates it has been approved for re-gossiping on +/// the p2p network. +#[derive(Educe)] +#[educe(Debug(bound = "T: BeaconChainTypes"))] +pub struct GossipVerifiedEnvelope<T: BeaconChainTypes> { + pub signed_envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + pub block: Arc<SignedBeaconBlock<T::EthSpec>>, + pub snapshot: Option<Box<EnvelopeProcessingSnapshot<T::EthSpec>>>, +} + +impl<T: BeaconChainTypes> GossipVerifiedEnvelope<T> { + pub fn new( + signed_envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result<Self, EnvelopeError> { + let envelope = &signed_envelope.message; + let beacon_block_root = envelope.beacon_block_root; + + // Check that we've seen the beacon block for this envelope and that it passes validation. + // TODO(EIP-7732): We might need some type of status table in order to differentiate between: + // If we have a block_processing_table, we could have a Processed(Bid, bool) state that is only + // entered post adding to fork choice. That way, we could potentially need only a single call to make + // sure the block is valid and to do all consequent checks with the bid + // + // 1. Blocks we haven't seen (IGNORE), and + // 2. Blocks we've seen that are invalid (REJECT). + // + // Presently these two cases are conflated. + let fork_choice_read_lock = ctx.canonical_head.fork_choice_read_lock(); + let Some(proto_block) = fork_choice_read_lock.get_block(&beacon_block_root) else { + return Err(EnvelopeError::BlockRootUnknown { + block_root: beacon_block_root, + }); + }; + + drop(fork_choice_read_lock); + + let latest_finalized_slot = ctx + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch + .start_slot(T::EthSpec::slots_per_epoch()); + + // TODO(EIP-7732): check that we haven't seen another valid `SignedExecutionPayloadEnvelope` + // for this block root from this builder - envelope status table check + let block = match ctx.store.try_get_full_block(&beacon_block_root)? { + Some(DatabaseBlock::Full(block)) => Arc::new(block), + Some(DatabaseBlock::Blinded(_)) | None => { + return Err(EnvelopeError::from(BeaconChainError::MissingBeaconBlock( + beacon_block_root, + ))); + } + }; + let execution_bid = &block + .message() + .body() + .signed_execution_payload_bid()? + .message; + + verify_envelope_consistency(envelope, &block, execution_bid, latest_finalized_slot)?; + + // Verify the envelope signature. + // + // For self-built envelopes, we can use the proposer cache for the fork and the + // validator pubkey cache for the proposer's pubkey, avoiding a state load from disk. + // For external builder envelopes, we must load the state to access the builder registry. + let builder_index = envelope.builder_index; + let block_slot = envelope.slot(); + let envelope_epoch = block_slot.epoch(T::EthSpec::slots_per_epoch()); + // Since the payload's block is already guaranteed to be imported, the associated `proto_block.current_epoch_shuffling_id` + // already carries the correct `shuffling_decision_block`. + let proposer_shuffling_decision_block = proto_block + .current_epoch_shuffling_id + .shuffling_decision_block; + + let (signature_is_valid, opt_snapshot) = if builder_index == BUILDER_INDEX_SELF_BUILD { + // Fast path: self-built envelopes can be verified without loading the state. + let mut opt_snapshot = None; + let proposer = beacon_proposer_cache::with_proposer_cache( + ctx.beacon_proposer_cache, + proposer_shuffling_decision_block, + envelope_epoch, + |proposers| proposers.get_slot::<T::EthSpec>(block_slot), + || { + debug!( + %beacon_block_root, + "Proposer shuffling cache miss for envelope verification" + ); + let snapshot = load_snapshot_from_state_root::<T>( + beacon_block_root, + proto_block.state_root, + ctx.store, + )?; + opt_snapshot = Some(Box::new(snapshot.clone())); + Ok::<_, EnvelopeError>((snapshot.state_root, snapshot.pre_state)) + }, + ctx.spec, + )?; + let expected_proposer = proposer.index; + let fork = proposer.fork; + + if block.message().proposer_index() != expected_proposer as u64 { + return Err(EnvelopeError::IncorrectBlockProposer { + proposer_index: block.message().proposer_index(), + local_shuffling: expected_proposer as u64, + }); + } + + let pubkey_cache = ctx.validator_pubkey_cache.read(); + let pubkey = pubkey_cache + .get(block.message().proposer_index() as usize) + .ok_or_else(|| EnvelopeError::UnknownValidator { + proposer_index: block.message().proposer_index(), + })?; + let is_valid = signed_envelope.verify_signature( + pubkey, + &fork, + ctx.genesis_validators_root, + ctx.spec, + ); + (is_valid, opt_snapshot) + } else { + // TODO(gloas) if we implement a builder pubkey cache, we'll need to use it here. + // External builder: must load the state to get the builder pubkey. + let snapshot = load_snapshot_from_state_root::<T>( + beacon_block_root, + proto_block.state_root, + ctx.store, + )?; + let is_valid = + signed_envelope.verify_signature_with_state(&snapshot.pre_state, ctx.spec)?; + (is_valid, Some(Box::new(snapshot))) + }; + + if !signature_is_valid { + return Err(EnvelopeError::BadSignature); + } + + if let Some(event_handler) = ctx.event_handler.as_ref() + && event_handler.has_execution_payload_gossip_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadGossip( + SseExecutionPayloadGossip { + slot: block.slot(), + builder_index, + block_hash: signed_envelope.message.payload.block_hash, + block_root: beacon_block_root, + }, + )); + } + + Ok(Self { + signed_envelope, + block, + snapshot: opt_snapshot, + }) + } + + pub fn envelope_cloned(&self) -> Arc<SignedExecutionPayloadEnvelope<T::EthSpec>> { + self.signed_envelope.clone() + } +} + +impl<T: BeaconChainTypes> BeaconChain<T> { + /// Build a `GossipVerificationContext` from this `BeaconChain` for `GossipVerifiedEnvelope`. + pub fn payload_envelope_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + store: &self.store, + spec: &self.spec, + beacon_proposer_cache: &self.beacon_proposer_cache, + validator_pubkey_cache: &self.validator_pubkey_cache, + genesis_validators_root: self.genesis_validators_root, + event_handler: &self.event_handler, + } + } + + /// Returns `Ok(GossipVerifiedEnvelope)` if the supplied `envelope` should be forwarded onto the + /// gossip network. The envelope is not imported into the chain, it is just partially verified. + /// + /// The returned `GossipVerifiedEnvelope` should be provided to `Self::process_execution_payload_envelope` immediately + /// after it is returned, unless some other circumstance decides it should not be imported at + /// all. + /// + /// ## Errors + /// + /// Returns an `Err` if the given envelope was invalid, or an error was encountered during verification. + pub async fn verify_envelope_for_gossip( + self: &Arc<Self>, + envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + ) -> Result<GossipVerifiedEnvelope<T>, EnvelopeError> { + let chain = self.clone(); + self.task_executor + .clone() + .spawn_blocking_handle( + move || { + let slot = envelope.slot(); + let beacon_block_root = envelope.message.beacon_block_root; + + let ctx = chain.payload_envelope_gossip_verification_context(); + match GossipVerifiedEnvelope::new(envelope, &ctx) { + Ok(verified) => { + debug!( + %slot, + ?beacon_block_root, + "Successfully verified gossip envelope" + ); + + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + ?beacon_block_root, + %slot, + "Rejected gossip envelope" + ); + + Err(e) + } + } + }, + "gossip_envelope_verification_handle", + ) + .ok_or(BeaconChainError::RuntimeShutdown)? + .await + .map_err(BeaconChainError::TokioJoin)? + } +} + +#[cfg(test)] +mod tests { + use std::marker::PhantomData; + + use bls::Signature; + use ssz_types::VariableList; + use types::{ + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, Eth1Data, ExecutionBlockHash, + ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, + Graffiti, Hash256, MinimalEthSpec, SignedBeaconBlock, SignedExecutionPayloadBid, Slot, + SyncAggregate, + }; + + use super::verify_envelope_consistency; + use crate::payload_envelope_verification::EnvelopeError; + + type E = MinimalEthSpec; + + fn make_envelope( + slot: Slot, + builder_index: u64, + block_hash: ExecutionBlockHash, + ) -> ExecutionPayloadEnvelope<E> { + ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + block_hash, + slot_number: slot, + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index, + beacon_block_root: Hash256::ZERO, + parent_beacon_block_root: Hash256::ZERO, + } + } + + fn make_block(slot: Slot) -> SignedBeaconBlock<E> { + let block = BeaconBlock::Gloas(BeaconBlockGloas { + slot, + proposer_index: 0, + parent_root: Hash256::ZERO, + state_root: Hash256::ZERO, + body: BeaconBlockBodyGloas { + randao_reveal: Signature::empty(), + eth1_data: Eth1Data { + deposit_root: Hash256::ZERO, + block_hash: Hash256::ZERO, + deposit_count: 0, + }, + graffiti: Graffiti::default(), + proposer_slashings: VariableList::empty(), + attester_slashings: VariableList::empty(), + attestations: VariableList::empty(), + deposits: VariableList::empty(), + voluntary_exits: VariableList::empty(), + sync_aggregate: SyncAggregate::empty(), + bls_to_execution_changes: VariableList::empty(), + parent_execution_requests: ExecutionRequests::default(), + signed_execution_payload_bid: SignedExecutionPayloadBid::empty(), + payload_attestations: VariableList::empty(), + _phantom: PhantomData, + }, + }); + SignedBeaconBlock::from_block(block, Signature::empty()) + } + + fn make_bid(builder_index: u64, block_hash: ExecutionBlockHash) -> ExecutionPayloadBid<E> { + ExecutionPayloadBid { + builder_index, + block_hash, + ..ExecutionPayloadBid::default() + } + } + + #[test] + fn test_valid_envelope() { + let slot = Slot::new(10); + let builder_index = 5; + let block_hash = ExecutionBlockHash::repeat_byte(0xaa); + + let envelope = make_envelope(slot, builder_index, block_hash); + let block = make_block(slot); + let bid = make_bid(builder_index, block_hash); + + assert!(verify_envelope_consistency::<E>(&envelope, &block, &bid, Slot::new(0)).is_ok()); + } + + #[test] + fn test_prior_to_finalization() { + let slot = Slot::new(5); + let builder_index = 1; + let block_hash = ExecutionBlockHash::repeat_byte(0xbb); + + let envelope = make_envelope(slot, builder_index, block_hash); + let block = make_block(slot); + let bid = make_bid(builder_index, block_hash); + let latest_finalized_slot = Slot::new(10); + + let result = + verify_envelope_consistency::<E>(&envelope, &block, &bid, latest_finalized_slot); + assert!(matches!( + result, + Err(EnvelopeError::PriorToFinalization { .. }) + )); + } + + #[test] + fn test_slot_mismatch() { + let builder_index = 1; + let block_hash = ExecutionBlockHash::repeat_byte(0xcc); + + let envelope = make_envelope(Slot::new(10), builder_index, block_hash); + let block = make_block(Slot::new(20)); + let bid = make_bid(builder_index, block_hash); + + let result = verify_envelope_consistency::<E>(&envelope, &block, &bid, Slot::new(0)); + assert!(matches!(result, Err(EnvelopeError::SlotMismatch { .. }))); + } + + #[test] + fn test_builder_index_mismatch() { + let slot = Slot::new(10); + let block_hash = ExecutionBlockHash::repeat_byte(0xdd); + + let envelope = make_envelope(slot, 1, block_hash); + let block = make_block(slot); + let bid = make_bid(2, block_hash); + + let result = verify_envelope_consistency::<E>(&envelope, &block, &bid, Slot::new(0)); + assert!(matches!( + result, + Err(EnvelopeError::BuilderIndexMismatch { .. }) + )); + } + + #[test] + fn test_block_hash_mismatch() { + let slot = Slot::new(10); + let builder_index = 1; + + let envelope = make_envelope(slot, builder_index, ExecutionBlockHash::repeat_byte(0xee)); + let block = make_block(slot); + let bid = make_bid(builder_index, ExecutionBlockHash::repeat_byte(0xff)); + + let result = verify_envelope_consistency::<E>(&envelope, &block, &bid, Slot::new(0)); + assert!(matches!( + result, + Err(EnvelopeError::BlockHashMismatch { .. }) + )); + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs new file mode 100644 index 0000000000..b40e8337fb --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -0,0 +1,380 @@ +use std::sync::Arc; +use std::time::Duration; + +use eth2::types::{EventKind, SseExecutionPayload, SseExecutionPayloadAvailable}; +use fork_choice::PayloadVerificationStatus; +use slot_clock::SlotClock; +use store::StoreOp; +use tracing::{debug, error, info, info_span, instrument, warn}; +use types::{BlockImportSource, Hash256, SignedExecutionPayloadEnvelope}; + +use super::{ + AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, EnvelopeImportData, + ExecutedEnvelope, gossip_verified_envelope::GossipVerifiedEnvelope, +}; +use crate::{ + AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, + NotifyExecutionLayer, block_verification_types::AvailableBlockData, metrics, + payload_envelope_verification::ExecutionPendingEnvelope, validator_monitor::get_slot_delay_ms, +}; + +const ENVELOPE_METRICS_CACHE_SLOT_LIMIT: u32 = 64; + +impl<T: BeaconChainTypes> BeaconChain<T> { + /// Returns `Ok(status)` if the given `unverified_envelope` was successfully verified and + /// imported into the chain. + /// + /// ## Errors + /// + /// Returns an `Err` if the given payload envelope was invalid, or an error was encountered during + /// verification. + #[instrument(skip_all, fields(block_root = ?block_root, block_source = %block_source))] + pub async fn process_execution_payload_envelope( + self: &Arc<Self>, + block_root: Hash256, + unverified_envelope: GossipVerifiedEnvelope<T>, + notify_execution_layer: NotifyExecutionLayer, + block_source: BlockImportSource, + publish_fn: impl FnOnce() -> Result<(), EnvelopeError>, + ) -> Result<AvailabilityProcessingStatus, EnvelopeError> { + let block_slot = unverified_envelope.signed_envelope.slot(); + + // Set observed time if not already set. Usually this should be set by gossip or RPC, + // but just in case we set it again here (useful for tests). + if let Some(seen_timestamp) = self.slot_clock.now_duration() { + self.envelope_times_cache.write().set_time_observed( + block_root, + block_slot, + seen_timestamp, + None, + ); + } + + // TODO(gloas) insert the pre-executed envelope into some type of cache. + + let _full_timer = metrics::start_timer(&metrics::ENVELOPE_PROCESSING_TIMES); + + metrics::inc_counter(&metrics::ENVELOPE_PROCESSING_REQUESTS); + + // A small closure to group the verification and import errors. + let chain = self.clone(); + let import_envelope = async move { + let execution_pending = unverified_envelope + .into_execution_pending_envelope(&chain, notify_execution_layer)?; + publish_fn()?; + + // Record the time it took to complete consensus verification. + if let Some(timestamp) = chain.slot_clock.now_duration() { + chain + .envelope_times_cache + .write() + .set_time_consensus_verified(block_root, block_slot, timestamp); + } + + let envelope_times_cache = chain.envelope_times_cache.clone(); + let slot_clock = chain.slot_clock.clone(); + + // TODO(gloas): rename/refactor these `into_` names to be less similar and more clear + // about what the function actually does. + let executed_envelope = chain + .into_executed_payload_envelope(execution_pending) + .await + .inspect_err(|_| { + // TODO(gloas) If the envelope fails execution for whatever reason (e.g. engine offline), + // and we keep it in the cache, then the node will NOT perform lookup and + // reprocess this block until the block is evicted from DA checker, causing the + // chain to get stuck temporarily if the block is canonical. Therefore we remove + // it from the cache if execution fails. + })?; + + // Record the time it took to wait for execution layer verification. + if let Some(timestamp) = slot_clock.now_duration() { + envelope_times_cache + .write() + .set_time_executed(block_root, block_slot, timestamp); + } + + match executed_envelope { + ExecutedEnvelope::Available(envelope) => { + self.import_available_execution_payload_envelope(Box::new(envelope)) + .await + } + ExecutedEnvelope::AvailabilityPending() => Err(EnvelopeError::InternalError( + "Pending payload envelope not yet implemented".to_owned(), + )), + } + }; + + // Verify and import the payload envelope. + match import_envelope.await { + // The payload envelope was successfully verified and imported. + Ok(status @ AvailabilityProcessingStatus::Imported(block_root)) => { + info!( + ?block_root, + %block_slot, + source = %block_source, + "Execution payload envelope imported" + ); + + // TODO(gloas) do we need to send a `PayloadImported` event to the reprocess queue? + // TODO(gloas) do we need to recompute head? + // should canonical_head return the block and the payload now? + self.recompute_head_at_current_slot().await; + + metrics::inc_counter(&metrics::ENVELOPE_PROCESSING_SUCCESSES); + + Ok(status) + } + Ok(status @ AvailabilityProcessingStatus::MissingComponents(slot, block_root)) => { + debug!(?block_root, %slot, "Payload envelope awaiting blobs"); + + Ok(status) + } + Err(EnvelopeError::BeaconChainError(e)) => { + if matches!(e.as_ref(), BeaconChainError::TokioJoin(_)) { + debug!(error = ?e, "Envelope processing cancelled"); + } else { + warn!(error = ?e, "Execution payload envelope rejected"); + } + Err(EnvelopeError::BeaconChainError(e)) + } + Err(other) => { + warn!( + reason = other.to_string(), + "Execution payload envelope rejected" + ); + Err(other) + } + } + } + + /// Accepts a fully-verified payload envelope and awaits on its payload verification handle to + /// get a fully `ExecutedEnvelope`. + /// + /// An error is returned if the verification handle couldn't be awaited. + #[instrument(skip_all, level = "debug")] + async fn into_executed_payload_envelope( + self: Arc<Self>, + pending_envelope: ExecutionPendingEnvelope<T::EthSpec>, + ) -> Result<ExecutedEnvelope<T::EthSpec>, EnvelopeError> { + let ExecutionPendingEnvelope { + signed_envelope, + import_data, + payload_verification_handle, + } = pending_envelope; + + let payload_verification_outcome = payload_verification_handle + .await + .map_err(BeaconChainError::TokioJoin)? + .ok_or(BeaconChainError::RuntimeShutdown)??; + + // TODO(gloas): optimistic sync is not supported for Gloas, maybe we could re-add it + if payload_verification_outcome + .payload_verification_status + .is_optimistic() + { + return Err(EnvelopeError::OptimisticSyncNotSupported { + block_root: import_data.block_root, + }); + } + + Ok(ExecutedEnvelope::new( + signed_envelope, + import_data, + payload_verification_outcome, + self.spec.clone(), + )) + } + + #[instrument(skip_all)] + pub async fn import_available_execution_payload_envelope( + self: &Arc<Self>, + envelope: Box<AvailableExecutedEnvelope<T::EthSpec>>, + ) -> Result<AvailabilityProcessingStatus, EnvelopeError> { + let AvailableExecutedEnvelope { + envelope, + import_data, + payload_verification_outcome, + } = *envelope; + + let EnvelopeImportData { + block_root, + _phantom, + } = import_data; + + let block_root = { + let chain = self.clone(); + self.spawn_blocking_handle( + move || { + chain.import_execution_payload_envelope( + envelope, + block_root, + payload_verification_outcome.payload_verification_status, + ) + }, + "payload_verification_handle", + ) + .await?? + }; + + Ok(AvailabilityProcessingStatus::Imported(block_root)) + } + + /// Accepts a fully-verified and available envelope and imports it into the chain without performing any + /// additional verification. + /// + /// An error is returned if the envelope was unable to be imported. It may be partially imported + /// (i.e., this function is not atomic). + #[allow(clippy::too_many_arguments)] + #[instrument(skip_all)] + fn import_execution_payload_envelope( + &self, + signed_envelope: AvailableEnvelope<T::EthSpec>, + block_root: Hash256, + payload_verification_status: PayloadVerificationStatus, + ) -> Result<Hash256, EnvelopeError> { + // Everything in this initial section is on the hot path for processing the envelope. + // Take an upgradable read lock on fork choice so we can check if this block has already + // been imported. We don't want to repeat work importing a block that is already imported. + let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); + if !fork_choice_reader.contains_block(&block_root) { + return Err(EnvelopeError::BlockRootUnknown { block_root }); + } + + // TODO(gloas) add defensive check to see if payload envelope is already in fork choice + // Note that a duplicate cache/payload status table should prevent this from happening + // but it doesnt hurt to be defensive. + + // Take an exclusive write-lock on fork choice. It's very important to prevent deadlocks by + // avoiding taking other locks whilst holding this lock. + let mut fork_choice = parking_lot::RwLockUpgradableReadGuard::upgrade(fork_choice_reader); + + // Update the block's payload to received in fork choice, which creates the `Full` virtual + // node which can be eligible for head. + fork_choice + .on_valid_payload_envelope_received(block_root) + .map_err(|e| EnvelopeError::InternalError(format!("{e:?}")))?; + + // TODO(gloas) emit SSE event if the payload became the new head payload + + // It is important NOT to return errors here before the database commit, because the envelope + // has already been added to fork choice and the database would be left in an inconsistent + // state if we returned early without committing. In other words, an error here would + // corrupt the node's database permanently. + + // Store the envelope, its post-state, and any data columns. + // If the write fails, revert fork choice to the version from disk, else we can + // end up with envelopes in fork choice that are missing from disk. + // See https://github.com/sigp/lighthouse/issues/2028 + let (signed_envelope, columns) = signed_envelope.deconstruct(); + + let mut ops = vec![]; + + if let Some(blobs_or_columns_store_op) = self.get_blobs_or_columns_store_op( + block_root, + signed_envelope.slot(), + AvailableBlockData::DataColumns(columns), + ) { + ops.push(blobs_or_columns_store_op); + } + + let db_write_timer = metrics::start_timer(&metrics::ENVELOPE_PROCESSING_DB_WRITE); + + ops.push(StoreOp::PutPayloadEnvelope( + block_root, + signed_envelope.clone(), + )); + + let db_span = info_span!("persist_payloads_and_blobs").entered(); + + if let Err(e) = self.store.do_atomically_with_block_and_blobs_cache(ops) { + error!( + msg = "Restoring fork choice from disk", + error = ?e, + "Database write failed!" + ); + return Err(e.into()); + // TODO(gloas) handle db write failure + // return Err(self + // .handle_import_block_db_write_error(fork_choice) + // .err() + // .unwrap_or(e.into())); + } + + drop(db_span); + + // The fork choice write-lock is dropped *after* the on-disk database has been updated. + // This prevents inconsistency between the two at the expense of concurrency. + drop(fork_choice); + + // We're declaring the envelope "imported" at this point, since fork choice and the DB know + // about it. + let envelope_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); + + // TODO(gloas) depending on what happens with light clients + // we might need to do some light client related computations here + + metrics::stop_timer(db_write_timer); + + self.import_envelope_update_metrics_and_events( + signed_envelope, + block_root, + payload_verification_status, + envelope_time_imported, + ); + + Ok(block_root) + } + + fn import_envelope_update_metrics_and_events( + &self, + signed_envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + block_root: Hash256, + payload_verification_status: PayloadVerificationStatus, + envelope_time_imported: Duration, + ) { + let envelope_slot = signed_envelope.slot(); + let envelope_delay_total = + get_slot_delay_ms(envelope_time_imported, envelope_slot, &self.slot_clock); + + // Do not write to the cache for envelopes older than 2 epochs, this helps reduce writes + // to the cache during sync. + if envelope_delay_total + < self + .slot_clock + .slot_duration() + .saturating_mul(ENVELOPE_METRICS_CACHE_SLOT_LIMIT) + { + self.envelope_times_cache.write().set_time_imported( + block_root, + envelope_slot, + envelope_time_imported, + ); + } + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_execution_payload_subscribers() + { + event_handler.register(EventKind::ExecutionPayload(SseExecutionPayload { + slot: envelope_slot, + builder_index: signed_envelope.message.builder_index, + block_hash: signed_envelope.block_hash(), + block_root, + execution_optimistic: payload_verification_status.is_optimistic(), + })); + } + + // TODO(gloas): once the DA checker handles envelopes, this event should also be + // emitted from the DA resolution path (similar to `process_availability` for blocks). + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_execution_payload_available_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadAvailable( + SseExecutionPayloadAvailable { + slot: envelope_slot, + block_root, + }, + )); + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs new file mode 100644 index 0000000000..b153a3cd6a --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -0,0 +1,307 @@ +//! The incremental processing steps (e.g., signatures verified but not the state transition) is +//! represented as a sequence of wrapper-types around the envelope. There is a linear progression of +//! types, starting at a `SignedExecutionPayloadEnvelope` and finishing with an `AvailableExecutedEnvelope` (see +//! diagram below). +//! +//! ```ignore +//! SignedExecutionPayloadEnvelope +//! | +//! ▼ +//! GossipVerifiedEnvelope +//! | +//! ▼ +//! ExecutionPendingEnvelope +//! | +//! await +//! ▼ +//! ExecutedEnvelope +//! +//! ``` + +use std::marker::PhantomData; +use std::sync::Arc; + +use state_processing::{BlockProcessingError, envelope_processing::EnvelopeProcessingError}; +use store::Error as DBError; +use tracing::instrument; +use types::{ + BeaconState, BeaconStateError, ChainSpec, DataColumnSidecarList, EthSpec, ExecutionBlockHash, + ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadEnvelope, Slot, +}; + +use crate::{ + BeaconChainError, BeaconChainTypes, BeaconStore, BlockError, ExecutionPayloadError, + PayloadVerificationOutcome, +}; + +pub mod execution_pending_envelope; +pub mod gossip_verified_envelope; +pub mod import; +mod payload_notifier; + +pub use execution_pending_envelope::ExecutionPendingEnvelope; + +// TODO(gloas): could remove this type completely, or remove the generic +#[derive(PartialEq)] +pub struct EnvelopeImportData<E: EthSpec> { + pub block_root: Hash256, + _phantom: PhantomData<E>, +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct AvailableEnvelope<E: EthSpec> { + execution_block_hash: ExecutionBlockHash, + envelope: Arc<SignedExecutionPayloadEnvelope<E>>, + columns: DataColumnSidecarList<E>, + /// Timestamp at which this envelope first became available (UNIX timestamp, time since 1970). + columns_available_timestamp: Option<std::time::Duration>, + pub spec: Arc<ChainSpec>, +} + +impl<E: EthSpec> AvailableEnvelope<E> { + pub fn new( + execution_block_hash: ExecutionBlockHash, + envelope: Arc<SignedExecutionPayloadEnvelope<E>>, + columns: DataColumnSidecarList<E>, + columns_available_timestamp: Option<std::time::Duration>, + spec: Arc<ChainSpec>, + ) -> Self { + Self { + execution_block_hash, + envelope, + columns, + columns_available_timestamp, + spec, + } + } + + pub fn message(&self) -> &ExecutionPayloadEnvelope<E> { + &self.envelope.message + } + + #[allow(clippy::type_complexity)] + pub fn deconstruct( + self, + ) -> ( + Arc<SignedExecutionPayloadEnvelope<E>>, + DataColumnSidecarList<E>, + ) { + let AvailableEnvelope { + envelope, columns, .. + } = self; + (envelope, columns) + } +} + +pub enum MaybeAvailableEnvelope<E: EthSpec> { + Available(AvailableEnvelope<E>), + AvailabilityPending { + block_hash: ExecutionBlockHash, + envelope: Arc<SignedExecutionPayloadEnvelope<E>>, + }, +} + +/// This snapshot is to be used for verifying a payload envelope. +#[derive(Debug, Clone)] +pub struct EnvelopeProcessingSnapshot<E: EthSpec> { + /// This state is equivalent to the `self.beacon_block.state_root()` before applying the envelope. + pub pre_state: BeaconState<E>, + pub state_root: Hash256, + pub beacon_block_root: Hash256, +} + +/// A payload envelope that has gone through processing checks and execution by an EL client. +/// This envelope hasn't necessarily completed data availability checks. +/// +/// +/// It contains 2 variants: +/// 1. `Available`: This envelope has been executed and also contains all data to consider it +/// fully available. +/// 2. `AvailabilityPending`: This envelope hasn't received all required blobs to consider it +/// fully available. +#[allow(dead_code)] +pub enum ExecutedEnvelope<E: EthSpec> { + Available(AvailableExecutedEnvelope<E>), + // TODO(gloas): check data column availability via DA checker + AvailabilityPending(), +} + +impl<E: EthSpec> ExecutedEnvelope<E> { + pub fn new( + envelope: MaybeAvailableEnvelope<E>, + import_data: EnvelopeImportData<E>, + payload_verification_outcome: PayloadVerificationOutcome, + spec: Arc<ChainSpec>, + ) -> Self { + match envelope { + MaybeAvailableEnvelope::Available(available_envelope) => { + Self::Available(AvailableExecutedEnvelope::new( + available_envelope, + import_data, + payload_verification_outcome, + )) + } + // TODO(gloas): check data column availability via DA checker + MaybeAvailableEnvelope::AvailabilityPending { + block_hash, + envelope, + } => Self::Available(AvailableExecutedEnvelope::new( + AvailableEnvelope::new(block_hash, envelope, vec![], None, spec), + import_data, + payload_verification_outcome, + )), + } + } +} + +/// A payload envelope that has completed all payload processing checks including verification +/// by an EL client **and** has all requisite blob data to be imported into fork choice. +pub struct AvailableExecutedEnvelope<E: EthSpec> { + pub envelope: AvailableEnvelope<E>, + pub import_data: EnvelopeImportData<E>, + pub payload_verification_outcome: PayloadVerificationOutcome, +} + +impl<E: EthSpec> AvailableExecutedEnvelope<E> { + pub fn new( + envelope: AvailableEnvelope<E>, + import_data: EnvelopeImportData<E>, + payload_verification_outcome: PayloadVerificationOutcome, + ) -> Self { + Self { + envelope, + import_data, + payload_verification_outcome, + } + } +} + +#[derive(Debug)] +pub enum EnvelopeError { + /// The envelope's block root is unknown. + BlockRootUnknown { block_root: Hash256 }, + /// The signature is invalid. + BadSignature, + /// The builder index doesn't match the committed bid + BuilderIndexMismatch { committed_bid: u64, envelope: u64 }, + /// The envelope slot doesn't match the block + SlotMismatch { block: Slot, envelope: Slot }, + /// The validator index is unknown + UnknownValidator { proposer_index: u64 }, + /// The block hash doesn't match the committed bid + BlockHashMismatch { + committed_bid: ExecutionBlockHash, + envelope: ExecutionBlockHash, + }, + /// The block's proposer_index does not match the locally computed proposer + IncorrectBlockProposer { + proposer_index: u64, + local_shuffling: u64, + }, + /// The slot belongs to a block that is from a slot prior than + /// to most recently finalized slot + PriorToFinalization { + payload_slot: Slot, + latest_finalized_slot: Slot, + }, + /// Optimistic sync is not supported for Gloas payload envelopes. + OptimisticSyncNotSupported { block_root: Hash256 }, + /// Some Beacon Chain Error + BeaconChainError(Arc<BeaconChainError>), + /// Some Beacon State error + BeaconStateError(BeaconStateError), + /// Some BlockProcessingError (for electra operations) + BlockProcessingError(BlockProcessingError), + /// Some EnvelopeProcessingError + EnvelopeProcessingError(EnvelopeProcessingError), + /// Error verifying the execution payload + ExecutionPayloadError(ExecutionPayloadError), + /// An error from block-level checks reused during envelope import + BlockError(BlockError), + /// Internal error + InternalError(String), +} + +impl std::fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From<BeaconChainError> for EnvelopeError { + fn from(e: BeaconChainError) -> Self { + EnvelopeError::BeaconChainError(Arc::new(e)) + } +} + +impl From<ExecutionPayloadError> for EnvelopeError { + fn from(e: ExecutionPayloadError) -> Self { + EnvelopeError::ExecutionPayloadError(e) + } +} + +impl From<BeaconStateError> for EnvelopeError { + fn from(e: BeaconStateError) -> Self { + EnvelopeError::BeaconStateError(e) + } +} + +impl From<DBError> for EnvelopeError { + fn from(e: DBError) -> Self { + EnvelopeError::BeaconChainError(Arc::new(BeaconChainError::DBError(e))) + } +} + +impl From<BlockError> for EnvelopeError { + fn from(e: BlockError) -> Self { + EnvelopeError::BlockError(e) + } +} + +/// Pull errors up from EnvelopeProcessingError to EnvelopeError +impl From<EnvelopeProcessingError> for EnvelopeError { + fn from(e: EnvelopeProcessingError) -> Self { + match e { + EnvelopeProcessingError::BadSignature => EnvelopeError::BadSignature, + EnvelopeProcessingError::BeaconStateError(e) => EnvelopeError::BeaconStateError(e), + EnvelopeProcessingError::BlockHashMismatch { + committed_bid, + envelope, + } => EnvelopeError::BlockHashMismatch { + committed_bid, + envelope, + }, + e => EnvelopeError::EnvelopeProcessingError(e), + } + } +} + +#[instrument(skip_all, level = "debug", fields(beacon_block_root = %beacon_block_root))] +/// Load state from store given a known state root and block root. +/// Use this when the proto block has already been looked up from fork choice. +pub(crate) fn load_snapshot_from_state_root<T: BeaconChainTypes>( + beacon_block_root: Hash256, + block_state_root: Hash256, + store: &BeaconStore<T>, +) -> Result<EnvelopeProcessingSnapshot<T::EthSpec>, EnvelopeError> { + // TODO(EIP-7732): add metrics here + + // We can use `get_hot_state` here rather than `get_advanced_hot_state` because the envelope + // must be from the same slot as its block (so no advance is required). + let cache_state = true; + let state = store + .get_hot_state(&block_state_root, cache_state) + .map_err(EnvelopeError::from)? + .ok_or_else(|| { + BeaconChainError::DBInconsistent(format!( + "Missing state for envelope block {block_state_root:?}", + )) + })?; + + Ok(EnvelopeProcessingSnapshot { + pre_state: state, + state_root: block_state_root, + beacon_block_root, + }) +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs new file mode 100644 index 0000000000..10fa86cffe --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; + +use execution_layer::{NewPayloadRequest, NewPayloadRequestGloas}; +use fork_choice::PayloadVerificationStatus; +use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; +use tracing::warn; +use types::{SignedBeaconBlock, SignedExecutionPayloadEnvelope}; + +use crate::{ + BeaconChain, BeaconChainTypes, BlockError, NotifyExecutionLayer, + execution_payload::notify_new_payload_with_request, + payload_envelope_verification::EnvelopeError, +}; + +/// Used to await the result of executing payload with a remote EE. +pub struct PayloadNotifier<T: BeaconChainTypes> { + pub chain: Arc<BeaconChain<T>>, + envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + block: Arc<SignedBeaconBlock<T::EthSpec>>, + payload_verification_status: Option<PayloadVerificationStatus>, +} + +impl<T: BeaconChainTypes> PayloadNotifier<T> { + pub fn new( + chain: Arc<BeaconChain<T>>, + envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + block: Arc<SignedBeaconBlock<T::EthSpec>>, + notify_execution_layer: NotifyExecutionLayer, + ) -> Result<Self, EnvelopeError> { + let payload_verification_status = { + let payload_message = &envelope.message; + + match notify_execution_layer { + NotifyExecutionLayer::No if chain.config.optimistic_finalized_sync => { + let new_payload_request = Self::build_new_payload_request(&envelope, &block)?; + // TODO(gloas): check and test RLP block hash calculation post-Gloas + if let Err(e) = new_payload_request.perform_optimistic_sync_verifications() { + warn!( + block_number = ?payload_message.payload.block_number, + info = "you can silence this warning with --disable-optimistic-finalized-sync", + error = ?e, + "Falling back to slow block hash verification" + ); + None + } else { + Some(PayloadVerificationStatus::Optimistic) + } + } + _ => None, + } + }; + + Ok(Self { + chain, + envelope, + block, + payload_verification_status, + }) + } + + pub async fn notify_new_payload(self) -> Result<PayloadVerificationStatus, BlockError> { + if let Some(precomputed_status) = self.payload_verification_status { + Ok(precomputed_status) + } else { + let parent_root = self.block.message().parent_root(); + let request = Self::build_new_payload_request(&self.envelope, &self.block)?; + notify_new_payload_with_request(&self.chain, self.envelope.slot(), parent_root, request).await + } + } + + fn build_new_payload_request<'a>( + envelope: &'a SignedExecutionPayloadEnvelope<T::EthSpec>, + block: &'a SignedBeaconBlock<T::EthSpec>, + ) -> Result<NewPayloadRequest<'a, T::EthSpec>, BlockError> { + let bid = &block + .message() + .body() + .signed_execution_payload_bid() + .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))? + .message; + + let versioned_hashes = bid + .blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + Ok(NewPayloadRequest::Gloas(NewPayloadRequestGloas { + execution_payload: &envelope.message.payload, + versioned_hashes, + parent_beacon_block_root: envelope.message.parent_beacon_block_root, + execution_requests: &envelope.message.execution_requests, + il_transactions: Default::default(), + })) + } +} diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs new file mode 100644 index 0000000000..8f7568d017 --- /dev/null +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -0,0 +1,206 @@ +//! Provides the `PendingPayloadEnvelopes` cache for storing execution payload envelopes +//! that have been produced during local block production. +//! +//! For local building, the envelope is created during block production. +//! This cache holds the envelopes temporarily until the validator fetches, signs, +//! and publishes the payload. + +use std::collections::HashMap; +use types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Slot}; + +pub struct PendingEnvelopeData<E: EthSpec> { + pub envelope: ExecutionPayloadEnvelope<E>, + pub blobs: Option<BlobsList<E>>, +} + +/// Cache for pending execution payload envelopes awaiting publishing. +/// +/// Envelopes are keyed by slot and pruned based on slot age. +/// This cache is only used for local building. +pub struct PendingPayloadEnvelopes<E: EthSpec> { + /// Maximum number of slots to keep envelopes before pruning. + max_slot_age: u64, + /// The envelopes, keyed by slot. + envelopes: HashMap<Slot, PendingEnvelopeData<E>>, +} + +impl<E: EthSpec> Default for PendingPayloadEnvelopes<E> { + fn default() -> Self { + Self::new(Self::DEFAULT_MAX_SLOT_AGE) + } +} + +impl<E: EthSpec> PendingPayloadEnvelopes<E> { + /// Default maximum slot age before pruning (2 slots). + pub const DEFAULT_MAX_SLOT_AGE: u64 = 2; + + /// Create a new cache with the specified maximum slot age. + pub fn new(max_slot_age: u64) -> Self { + Self { + max_slot_age, + envelopes: HashMap::new(), + } + } + + /// Insert a pending envelope into the cache. + pub fn insert(&mut self, slot: Slot, data: PendingEnvelopeData<E>) { + // TODO(gloas): we may want to check for duplicates here, which shouldn't be allowed + self.envelopes.insert(slot, data); + } + + /// Get a pending envelope by slot. + pub fn get(&self, slot: Slot) -> Option<&ExecutionPayloadEnvelope<E>> { + self.envelopes.get(&slot).map(|d| &d.envelope) + } + + /// Remove and return the blobs and proofs for a slot, leaving the envelope in place. + pub fn take_blobs(&mut self, slot: Slot) -> Option<BlobsList<E>> { + self.envelopes.get_mut(&slot).and_then(|d| d.blobs.take()) + } + + /// Remove and return a pending envelope by slot. + pub fn remove(&mut self, slot: Slot) -> Option<ExecutionPayloadEnvelope<E>> { + self.envelopes.remove(&slot).map(|d| d.envelope) + } + + /// Check if an envelope exists for the given slot. + pub fn contains(&self, slot: Slot) -> bool { + self.envelopes.contains_key(&slot) + } + + /// Prune envelopes older than `current_slot - max_slot_age`. + /// + /// This removes stale envelopes from blocks that were never published. + // TODO(gloas) implement pruning + pub fn prune(&mut self, current_slot: Slot) { + let min_slot = current_slot.saturating_sub(self.max_slot_age); + self.envelopes.retain(|slot, _| *slot >= min_slot); + } + + /// Returns the number of pending envelopes in the cache. + pub fn len(&self) -> usize { + self.envelopes.len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.envelopes.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use types::{ExecutionPayloadGloas, ExecutionRequests, Hash256, MainnetEthSpec}; + + type E = MainnetEthSpec; + + fn make_envelope(slot: Slot) -> PendingEnvelopeData<E> { + PendingEnvelopeData { + envelope: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + slot_number: slot, + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: Hash256::ZERO, + parent_beacon_block_root: Hash256::ZERO, + }, + blobs: None, + } + } + + #[test] + fn insert_and_get() { + let mut cache = PendingPayloadEnvelopes::<E>::default(); + let slot = Slot::new(1); + let data = make_envelope(slot); + let expected_envelope = data.envelope.clone(); + + assert!(!cache.contains(slot)); + assert_eq!(cache.len(), 0); + + cache.insert(slot, data); + + assert!(cache.contains(slot)); + assert_eq!(cache.len(), 1); + assert_eq!(cache.get(slot), Some(&expected_envelope)); + } + + #[test] + fn remove() { + let mut cache = PendingPayloadEnvelopes::<E>::default(); + let slot = Slot::new(1); + let data = make_envelope(slot); + let expected_envelope = data.envelope.clone(); + + cache.insert(slot, data); + assert!(cache.contains(slot)); + + let removed = cache.remove(slot); + assert_eq!(removed, Some(expected_envelope)); + assert!(!cache.contains(slot)); + assert_eq!(cache.len(), 0); + } + + #[test] + fn take_blobs_returns_once() { + let mut cache = PendingPayloadEnvelopes::<E>::default(); + let slot = Slot::new(1); + + let blobs = BlobsList::<E>::default(); + let data = PendingEnvelopeData { + envelope: make_envelope(slot).envelope, + blobs: Some(blobs), + }; + cache.insert(slot, data); + + // First take returns the blobs + let taken = cache.take_blobs(slot); + assert!(taken.is_some()); + + // Second take returns None — blobs are consumed + let taken_again = cache.take_blobs(slot); + assert!(taken_again.is_none()); + + // Envelope is still in the cache + assert!(cache.contains(slot)); + assert!(cache.get(slot).is_some()); + } + + #[test] + fn take_blobs_returns_none_when_absent() { + let mut cache = PendingPayloadEnvelopes::<E>::default(); + let slot = Slot::new(1); + + // Insert with no blobs + cache.insert(slot, make_envelope(slot)); + assert!(cache.take_blobs(slot).is_none()); + + // Non-existent slot + assert!(cache.take_blobs(Slot::new(99)).is_none()); + } + + #[test] + fn prune_old_envelopes() { + let mut cache = PendingPayloadEnvelopes::<E>::new(2); + + // Insert envelope at slot 5 + let slot_1 = Slot::new(5); + cache.insert(slot_1, make_envelope(slot_1)); + + // Insert envelope at slot 10 + let slot_2 = Slot::new(10); + cache.insert(slot_2, make_envelope(slot_2)); + + assert_eq!(cache.len(), 2); + + // Prune at slot 10 with max_slot_age=2, should keep slots >= 8 + cache.prune(Slot::new(10)); + + assert_eq!(cache.len(), 1); + assert!(!cache.contains(slot_1)); // slot 5 < 8, pruned + assert!(cache.contains(slot_2)); // slot 10 >= 8, kept + } +} diff --git a/beacon_node/beacon_chain/src/persisted_fork_choice.rs b/beacon_node/beacon_chain/src/persisted_fork_choice.rs index d8fcc0901b..8edccbbe98 100644 --- a/beacon_node/beacon_chain/src/persisted_fork_choice.rs +++ b/beacon_node/beacon_chain/src/persisted_fork_choice.rs @@ -1,52 +1,27 @@ -use crate::{ - beacon_fork_choice_store::{PersistedForkChoiceStoreV17, PersistedForkChoiceStoreV28}, - metrics, -}; +use crate::{beacon_fork_choice_store::PersistedForkChoiceStoreV28, metrics}; use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; -use store::{DBColumn, Error, KeyValueStoreOp, StoreConfig, StoreItem}; +use store::{DBColumn, Error, KeyValueStoreOp, StoreConfig}; use superstruct::superstruct; use types::Hash256; // If adding a new version you should update this type alias and fix the breakages. -pub type PersistedForkChoice = PersistedForkChoiceV28; +pub type PersistedForkChoice = PersistedForkChoiceV29; #[superstruct( - variants(V17, V28), + variants(V28, V29), variant_attributes(derive(Encode, Decode)), no_enum )] pub struct PersistedForkChoice { - #[superstruct(only(V17))] - pub fork_choice_v17: fork_choice::PersistedForkChoiceV17, - #[superstruct(only(V28))] - pub fork_choice: fork_choice::PersistedForkChoiceV28, - #[superstruct(only(V17))] - pub fork_choice_store_v17: PersistedForkChoiceStoreV17, #[superstruct(only(V28))] + pub fork_choice_v28: fork_choice::PersistedForkChoiceV28, + #[superstruct(only(V29))] + pub fork_choice: fork_choice::PersistedForkChoiceV29, + #[superstruct(only(V28, V29))] pub fork_choice_store: PersistedForkChoiceStoreV28, } -macro_rules! impl_store_item { - ($type:ty) => { - impl StoreItem for $type { - fn db_column() -> DBColumn { - DBColumn::ForkChoice - } - - fn as_store_bytes(&self) -> Vec<u8> { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> std::result::Result<Self, Error> { - Self::from_ssz_bytes(bytes).map_err(Into::into) - } - } - }; -} - -impl_store_item!(PersistedForkChoiceV17); - impl PersistedForkChoiceV28 { pub fn from_bytes(bytes: &[u8], store_config: &StoreConfig) -> Result<Self, Error> { let decompressed_bytes = store_config @@ -78,3 +53,53 @@ impl PersistedForkChoiceV28 { )) } } + +impl PersistedForkChoiceV29 { + pub fn from_bytes(bytes: &[u8], store_config: &StoreConfig) -> Result<Self, Error> { + let decompressed_bytes = store_config + .decompress_bytes(bytes) + .map_err(Error::Compression)?; + Self::from_ssz_bytes(&decompressed_bytes).map_err(Into::into) + } + + pub fn as_bytes(&self, store_config: &StoreConfig) -> Result<Vec<u8>, Error> { + let encode_timer = metrics::start_timer(&metrics::FORK_CHOICE_ENCODE_TIMES); + let ssz_bytes = self.as_ssz_bytes(); + drop(encode_timer); + + let _compress_timer = metrics::start_timer(&metrics::FORK_CHOICE_COMPRESS_TIMES); + store_config + .compress_bytes(&ssz_bytes) + .map_err(Error::Compression) + } + + pub fn as_kv_store_op( + &self, + key: Hash256, + store_config: &StoreConfig, + ) -> Result<KeyValueStoreOp, Error> { + Ok(KeyValueStoreOp::PutKeyValue( + DBColumn::ForkChoice, + key.as_slice().to_vec(), + self.as_bytes(store_config)?, + )) + } +} + +impl From<PersistedForkChoiceV28> for PersistedForkChoiceV29 { + fn from(v28: PersistedForkChoiceV28) -> Self { + Self { + fork_choice: v28.fork_choice_v28.into(), + fork_choice_store: v28.fork_choice_store, + } + } +} + +impl From<PersistedForkChoiceV29> for PersistedForkChoiceV28 { + fn from(v29: PersistedForkChoiceV29) -> Self { + Self { + fork_choice_v28: v29.fork_choice.into(), + fork_choice_store: v29.fork_choice_store, + } + } +} diff --git a/beacon_node/beacon_chain/src/pre_finalization_cache.rs b/beacon_node/beacon_chain/src/pre_finalization_cache.rs index 8996d6b874..54bd5c1940 100644 --- a/beacon_node/beacon_chain/src/pre_finalization_cache.rs +++ b/beacon_node/beacon_chain/src/pre_finalization_cache.rs @@ -6,7 +6,7 @@ use std::num::NonZeroUsize; use std::time::Duration; use tracing::debug; use types::Hash256; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; const BLOCK_ROOT_CACHE_LIMIT: NonZeroUsize = new_non_zero_usize(512); const LOOKUP_LIMIT: NonZeroUsize = new_non_zero_usize(8); diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs new file mode 100644 index 0000000000..e97dab56d7 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -0,0 +1,225 @@ +use std::sync::Arc; + +use crate::{ + BeaconChain, BeaconChainTypes, CanonicalHead, + proposer_preferences_verification::{ + ProposerPreferencesError, proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, +}; +use slot_clock::SlotClock; +use state_processing::signature_sets::{get_pubkey_from_state, proposer_preferences_signature_set}; +use tracing::debug; +use types::{ + BeaconState, ChainSpec, EthSpec, ProposerPreferences, SignedProposerPreferences, Slot, +}; + +/// Verify that proposer preferences are consistent with the current chain state +pub(crate) fn verify_preferences_consistency<E: EthSpec>( + preferences: &ProposerPreferences, + current_slot: Slot, + head_state: &BeaconState<E>, +) -> Result<(), ProposerPreferencesError> { + let proposal_slot = preferences.proposal_slot; + let validator_index = preferences.validator_index; + let current_epoch = current_slot.epoch(E::slots_per_epoch()); + let proposal_epoch = proposal_slot.epoch(E::slots_per_epoch()); + + if proposal_epoch < current_epoch || proposal_epoch > current_epoch.saturating_add(1u64) { + return Err(ProposerPreferencesError::InvalidProposalEpoch { proposal_epoch }); + } + + if proposal_slot <= current_slot { + return Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { + proposal_slot, + current_slot, + }); + } + + if !head_state.is_valid_proposal_slot(preferences)? { + return Err(ProposerPreferencesError::InvalidProposalSlot { + validator_index, + proposal_slot, + }); + } + + Ok(()) +} + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead<T>, + pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +/// A wrapper around `SignedProposerPreferences` that has been verified for gossip propagation. +#[derive(Debug, Clone)] +pub struct GossipVerifiedProposerPreferences { + pub signed_preferences: Arc<SignedProposerPreferences>, +} + +impl GossipVerifiedProposerPreferences { + pub fn new<T: BeaconChainTypes>( + signed_preferences: Arc<SignedProposerPreferences>, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result<Self, ProposerPreferencesError> { + let proposal_slot = signed_preferences.message.proposal_slot; + let checkpoint_root = signed_preferences.message.checkpoint_root; + let validator_index = signed_preferences.message.validator_index; + let cached_head = ctx.canonical_head.cached_head(); + let current_slot = ctx + .slot_clock + .now() + .ok_or(ProposerPreferencesError::UnableToReadSlot)?; + let head_state = &cached_head.snapshot.beacon_state; + + if ctx + .gossip_verified_proposer_preferences_cache + .get_seen_validator(&proposal_slot, checkpoint_root, validator_index) + { + return Err(ProposerPreferencesError::AlreadySeen { + validator_index, + proposal_slot, + }); + } + + verify_preferences_consistency(&signed_preferences.message, current_slot, head_state)?; + + // Verify signature + proposer_preferences_signature_set( + head_state, + |i| get_pubkey_from_state(head_state, i), + &signed_preferences, + ctx.spec, + ) + .map_err(|_| ProposerPreferencesError::BadSignature)? + .verify() + .then_some(()) + .ok_or(ProposerPreferencesError::BadSignature)?; + + let gossip_verified = GossipVerifiedProposerPreferences { signed_preferences }; + + ctx.gossip_verified_proposer_preferences_cache + .insert_seen_validator(&gossip_verified); + + ctx.gossip_verified_proposer_preferences_cache + .insert_preferences(gossip_verified.clone()); + + Ok(gossip_verified) + } +} + +impl<T: BeaconChainTypes> BeaconChain<T> { + pub fn proposer_preferences_gossip_verification_context( + &self, + ) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_proposer_preferences_cache: &self + .gossip_verified_proposer_preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + pub fn verify_proposer_preferences_for_gossip( + &self, + signed_preferences: Arc<SignedProposerPreferences>, + ) -> Result<GossipVerifiedProposerPreferences, ProposerPreferencesError> { + let proposal_slot = signed_preferences.message.proposal_slot; + let validator_index = signed_preferences.message.validator_index; + + let ctx = self.proposer_preferences_gossip_verification_context(); + match GossipVerifiedProposerPreferences::new(signed_preferences, &ctx) { + Ok(verified) => { + debug!( + %proposal_slot, + %validator_index, + "Successfully verified gossip proposer preferences" + ); + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + %proposal_slot, + %validator_index, + "Rejected gossip proposer preferences" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use types::{Address, BeaconState, EthSpec, MinimalEthSpec, ProposerPreferences, Slot}; + + use super::verify_preferences_consistency; + use crate::proposer_preferences_verification::ProposerPreferencesError; + + type E = MinimalEthSpec; + + fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences { + ProposerPreferences { + checkpoint_root: types::Hash256::ZERO, + proposal_slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + } + } + + fn state() -> BeaconState<E> { + BeaconState::new(0, <_>::default(), &E::default_spec()) + } + + #[test] + fn test_invalid_epoch_too_old() { + let current_slot = Slot::new(2 * E::slots_per_epoch()); + let prefs = make_preferences(Slot::new(3), 0); + + let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); + } + + #[test] + fn test_invalid_epoch_too_far_ahead() { + let current_slot = Slot::new(E::slots_per_epoch()); + let prefs = make_preferences(Slot::new(3 * E::slots_per_epoch() + 1), 0); + + let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); + } + + #[test] + fn test_proposal_slot_already_passed() { + let current_slot = Slot::new(10); + let prefs = make_preferences(Slot::new(9), 0); + + let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); + } + + #[test] + fn test_proposal_slot_equal_to_current() { + let current_slot = Slot::new(10); + let prefs = make_preferences(Slot::new(10), 0); + + let result = verify_preferences_consistency::<E>(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs new file mode 100644 index 0000000000..a2e96dfce1 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs @@ -0,0 +1,70 @@ +//! Gossip verification for proposer preferences. +//! +//! A `SignedProposerPreferences` is verified and wrapped as a `GossipVerifiedProposerPreferences`, +//! which is then inserted into the `GossipVerifiedProposerPreferenceCache`. +//! +//! ```ignore +//! SignedProposerPreferences +//! | +//! ▼ +//! GossipVerifiedProposerPreferences -------> Insert into GossipVerifiedProposerPreferenceCache +//! ``` + +use std::sync::Arc; + +use types::{BeaconStateError, Epoch, Slot}; + +use crate::BeaconChainError; + +pub mod gossip_verified_proposer_preferences; +pub mod proposer_preference_cache; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub enum ProposerPreferencesError { + /// The proposal slot is not in the current or next epoch. + InvalidProposalEpoch { proposal_epoch: Epoch }, + /// The proposal slot has already passed. + ProposalSlotAlreadyPassed { + proposal_slot: Slot, + current_slot: Slot, + }, + /// The validator index does not match the proposer at the given slot. + InvalidProposalSlot { + validator_index: u64, + proposal_slot: Slot, + }, + /// The slot clock cannot be read. + UnableToReadSlot, + /// A valid message from this validator for this slot has already been seen. + AlreadySeen { + validator_index: u64, + proposal_slot: Slot, + }, + /// The signature is invalid. + BadSignature, + /// Some Beacon Chain Error + BeaconChainError(Arc<BeaconChainError>), + /// Some Beacon State error + BeaconStateError(BeaconStateError), +} + +impl std::fmt::Display for ProposerPreferencesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From<BeaconStateError> for ProposerPreferencesError { + fn from(e: BeaconStateError) -> Self { + ProposerPreferencesError::BeaconStateError(e) + } +} + +impl From<BeaconChainError> for ProposerPreferencesError { + fn from(e: BeaconChainError) -> Self { + ProposerPreferencesError::BeaconChainError(Arc::new(e)) + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs new file mode 100644 index 0000000000..e2b0c40fb5 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -0,0 +1,114 @@ +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; + +use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; +use parking_lot::RwLock; +use types::{Hash256, SignedProposerPreferences, Slot}; + +pub struct GossipVerifiedProposerPreferenceCache { + preferences: RwLock<BTreeMap<Slot, GossipVerifiedProposerPreferences>>, + seen: RwLock<BTreeMap<Slot, HashSet<(Hash256, u64)>>>, +} + +impl Default for GossipVerifiedProposerPreferenceCache { + fn default() -> Self { + Self { + preferences: RwLock::new(BTreeMap::new()), + seen: RwLock::new(BTreeMap::new()), + } + } +} + +impl GossipVerifiedProposerPreferenceCache { + pub fn get_preferences(&self, slot: &Slot) -> Option<Arc<SignedProposerPreferences>> { + self.preferences + .read() + .get(slot) + .map(|p| p.signed_preferences.clone()) + } + + pub fn insert_preferences(&self, preferences: GossipVerifiedProposerPreferences) { + let slot = preferences.signed_preferences.message.proposal_slot; + self.preferences.write().insert(slot, preferences); + } + + pub fn get_seen_validator( + &self, + slot: &Slot, + checkpoint_root: Hash256, + validator_index: u64, + ) -> bool { + self.seen + .read() + .get(slot) + .is_some_and(|seen| seen.contains(&(checkpoint_root, validator_index))) + } + + pub fn insert_seen_validator(&self, preferences: &GossipVerifiedProposerPreferences) { + let slot = preferences.signed_preferences.message.proposal_slot; + let checkpoint_root = preferences.signed_preferences.message.checkpoint_root; + let validator_index = preferences.signed_preferences.message.validator_index; + self.seen + .write() + .entry(slot) + .or_default() + .insert((checkpoint_root, validator_index)); + } + + pub fn prune(&self, current_slot: Slot) { + self.preferences + .write() + .retain(|&slot, _| slot >= current_slot); + self.seen.write().retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bls::Signature; + use types::{Address, ProposerPreferences, SignedProposerPreferences, Slot}; + + use super::GossipVerifiedProposerPreferenceCache; + use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; + + fn make_gossip_verified(slot: Slot, validator_index: u64) -> GossipVerifiedProposerPreferences { + GossipVerifiedProposerPreferences { + signed_preferences: Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot: slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + }), + } + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedProposerPreferenceCache::default(); + + for slot in [1, 2, 3, 7, 8, 9, 10] { + let verified = make_gossip_verified(Slot::new(slot), slot); + cache.insert_seen_validator(&verified); + cache.insert_preferences(verified); + } + + cache.prune(Slot::new(8)); + + for slot in [1, 2, 3, 7] { + assert!(cache.get_preferences(&Slot::new(slot)).is_none()); + assert!(!cache.get_seen_validator(&Slot::new(slot), types::Hash256::ZERO, slot)); + } + for slot in [8, 9, 10] { + assert!(cache.get_preferences(&Slot::new(slot)).is_some()); + assert!(cache.get_seen_validator(&Slot::new(slot), types::Hash256::ZERO, slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs new file mode 100644 index 0000000000..d3974baa8b --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -0,0 +1,281 @@ +use std::sync::Arc; +use std::time::Duration; + +use bls::Signature; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use proto_array::PayloadStatus; +use slot_clock::{SlotClock, TestingSlotClock}; +use store::{HotColdDB, StoreConfig}; +use types::{ + Address, BeaconBlock, ChainSpec, Checkpoint, Epoch, EthSpec, Hash256, MinimalEthSpec, + ProposerPreferences, SignedBeaconBlock, SignedProposerPreferences, Slot, +}; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + proposer_preferences_verification::{ + ProposerPreferencesError, + gossip_verified_proposer_preferences::{ + GossipVerificationContext, GossipVerifiedProposerPreferences, + }, + proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, + test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType<E>; + +const NUM_VALIDATORS: usize = 64; + +struct TestContext { + canonical_head: CanonicalHead<T>, + preferences_cache: GossipVerifiedProposerPreferenceCache, + slot_clock: TestingSlotClock, + spec: ChainSpec, +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::<E>(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::<E>(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let mut genesis_block = BeaconBlock::empty(&spec); + *genesis_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + + Self { + canonical_head, + preferences_cache: GossipVerifiedProposerPreferenceCache::default(), + slot_clock, + spec, + } + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_proposer_preferences_cache: &self.preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + fn proposer_at_slot(&self, slot: Slot) -> u64 { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let lookahead = state + .proposer_lookahead() + .expect("Gloas state has lookahead"); + let slot_in_epoch = slot.as_usize() % E::slots_per_epoch() as usize; + let epoch = slot.epoch(E::slots_per_epoch()); + let current_epoch = state.slot().epoch(E::slots_per_epoch()); + let index = if epoch == current_epoch.saturating_add(1u64) { + E::slots_per_epoch() as usize + slot_in_epoch + } else { + slot_in_epoch + }; + *lookahead.get(index).expect("index in range") + } +} + +fn make_signed_preferences( + proposal_slot: Slot, + validator_index: u64, +) -> Arc<SignedProposerPreferences> { + Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + }) +} + +#[test] +fn already_seen_validator() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let verified = GossipVerifiedProposerPreferences { + signed_preferences: make_signed_preferences(slot, 42), + }; + ctx.preferences_cache.insert_seen_validator(&verified); + + let prefs = make_signed_preferences(slot, 42); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::AlreadySeen { + validator_index: 42, + .. + }) + )); +} + +#[test] +fn invalid_epoch_too_far_ahead() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let far_slot = Slot::new(3 * E::slots_per_epoch()); + let prefs = make_signed_preferences(far_slot, 0); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); +} + +#[test] +fn proposal_slot_already_passed() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let prefs = make_signed_preferences(Slot::new(0), 0); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); +} + +#[test] +fn wrong_proposer_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let actual_proposer = ctx.proposer_at_slot(slot); + let wrong_validator = if actual_proposer == 0 { 1 } else { 0 }; + + let prefs = make_signed_preferences(slot, wrong_validator); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalSlot { .. }) + )); +} + +#[test] +fn correct_proposer_bad_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let actual_proposer = ctx.proposer_at_slot(slot); + let prefs = make_signed_preferences(slot, actual_proposer); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::BadSignature) + )); + assert!(!ctx.preferences_cache.get_seen_validator( + &slot, + types::Hash256::ZERO, + actual_proposer + )); + assert!(ctx.preferences_cache.get_preferences(&slot).is_none()); +} + +#[test] +fn validator_index_out_of_bounds() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let prefs = make_signed_preferences(slot, u64::MAX); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalSlot { .. }) + )); +} + +// TODO(gloas) add successful proposer preferences check once we have proposer preferences signing logic + +#[test] +fn preferences_for_next_epoch_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + // Head is at slot 0 (epoch 0). Pick a slot in epoch 1. + let next_epoch_slot = Slot::new(E::slots_per_epoch() + 1); + let actual_proposer = ctx.proposer_at_slot(next_epoch_slot); + + let prefs = make_signed_preferences(next_epoch_slot, actual_proposer); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + // Should pass consistency checks but fail on signature (empty sig). + assert!( + matches!(result, Err(ProposerPreferencesError::BadSignature)), + "expected BadSignature for next-epoch slot, got: {:?}", + result + ); +} diff --git a/beacon_node/beacon_chain/src/schema_change.rs b/beacon_node/beacon_chain/src/schema_change.rs index ddc5978339..841f28e37d 100644 --- a/beacon_node/beacon_chain/src/schema_change.rs +++ b/beacon_node/beacon_chain/src/schema_change.rs @@ -1,18 +1,17 @@ //! Utilities for managing database schema changes. -mod migration_schema_v23; -mod migration_schema_v24; -mod migration_schema_v25; -mod migration_schema_v26; -mod migration_schema_v27; -mod migration_schema_v28; +mod migration_schema_v29; use crate::beacon_chain::BeaconChainTypes; +use migration_schema_v29::{downgrade_from_v29, upgrade_to_v29}; use std::sync::Arc; use store::Error as StoreError; use store::hot_cold_store::{HotColdDB, HotColdDBError}; use store::metadata::{CURRENT_SCHEMA_VERSION, SchemaVersion}; /// Migrate the database from one schema version to another, applying all requisite mutations. +/// +/// All migrations for schema versions up to and including v28 have been removed. Nodes on live +/// networks are already running v28, so only the current version check remains. pub fn migrate_schema<T: BeaconChainTypes>( db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, from: SchemaVersion, @@ -21,71 +20,14 @@ pub fn migrate_schema<T: BeaconChainTypes>( match (from, to) { // Migrating from the current schema version to itself is always OK, a no-op. (_, _) if from == to && to == CURRENT_SCHEMA_VERSION => Ok(()), - // Upgrade across multiple versions by recursively migrating one step at a time. - (_, _) if from.as_u64() + 1 < to.as_u64() => { - let next = SchemaVersion(from.as_u64() + 1); - migrate_schema::<T>(db.clone(), from, next)?; - migrate_schema::<T>(db, next, to) - } - // Downgrade across multiple versions by recursively migrating one step at a time. - (_, _) if to.as_u64() + 1 < from.as_u64() => { - let next = SchemaVersion(from.as_u64() - 1); - migrate_schema::<T>(db.clone(), from, next)?; - migrate_schema::<T>(db, next, to) - } - - // - // Migrations from before SchemaVersion(22) are deprecated. - // - (SchemaVersion(22), SchemaVersion(23)) => { - let ops = migration_schema_v23::upgrade_to_v23::<T>(db.clone())?; + // Upgrade from v28 to v29. + (SchemaVersion(28), SchemaVersion(29)) => { + let ops = upgrade_to_v29::<T>(&db)?; db.store_schema_version_atomically(to, ops) } - (SchemaVersion(23), SchemaVersion(22)) => { - let ops = migration_schema_v23::downgrade_from_v23::<T>(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(23), SchemaVersion(24)) => { - let ops = migration_schema_v24::upgrade_to_v24::<T>(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(24), SchemaVersion(23)) => { - let ops = migration_schema_v24::downgrade_from_v24::<T>(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(24), SchemaVersion(25)) => { - let ops = migration_schema_v25::upgrade_to_v25()?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(25), SchemaVersion(24)) => { - let ops = migration_schema_v25::downgrade_from_v25()?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(25), SchemaVersion(26)) => { - let ops = migration_schema_v26::upgrade_to_v26::<T>(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(26), SchemaVersion(25)) => { - let ops = migration_schema_v26::downgrade_from_v26::<T>(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(26), SchemaVersion(27)) => { - // This migration updates the blobs db. The schema version - // is bumped inside upgrade_to_v27. - migration_schema_v27::upgrade_to_v27::<T>(db.clone()) - } - (SchemaVersion(27), SchemaVersion(26)) => { - // Downgrading is essentially a no-op and is only possible - // if peer das isn't scheduled. - migration_schema_v27::downgrade_from_v27::<T>(db.clone())?; - db.store_schema_version_atomically(to, vec![]) - } - (SchemaVersion(27), SchemaVersion(28)) => { - let ops = migration_schema_v28::upgrade_to_v28::<T>(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(28), SchemaVersion(27)) => { - let ops = migration_schema_v28::downgrade_from_v28::<T>(db.clone())?; + // Downgrade from v29 to v28. + (SchemaVersion(29), SchemaVersion(28)) => { + let ops = downgrade_from_v29::<T>(&db)?; db.store_schema_version_atomically(to, ops) } // Anything else is an error. diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs deleted file mode 100644 index e238e1efb6..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs +++ /dev/null @@ -1,180 +0,0 @@ -use crate::BeaconForkChoiceStore; -use crate::beacon_chain::BeaconChainTypes; -use crate::persisted_fork_choice::PersistedForkChoiceV17; -use crate::schema_change::StoreError; -use crate::test_utils::{BEACON_CHAIN_DB_KEY, FORK_CHOICE_DB_KEY, PersistedBeaconChain}; -use fork_choice::{ForkChoice, ResetPayloadStatuses}; -use ssz::{Decode, Encode}; -use ssz_derive::{Decode, Encode}; -use std::sync::Arc; -use store::{DBColumn, Error, HotColdDB, KeyValueStore, KeyValueStoreOp, StoreItem}; -use tracing::{debug, info}; -use types::{Hash256, Slot}; - -/// Dummy value to use for the canonical head block root, see below. -pub const DUMMY_CANONICAL_HEAD_BLOCK_ROOT: Hash256 = Hash256::repeat_byte(0xff); - -pub fn upgrade_to_v23<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - info!("Upgrading DB schema from v22 to v23"); - - // 1) Set the head-tracker to empty - let Some(persisted_beacon_chain_v22) = - db.get_item::<PersistedBeaconChainV22>(&BEACON_CHAIN_DB_KEY)? - else { - return Err(Error::MigrationError( - "No persisted beacon chain found in DB. Datadir could be incorrect or DB could be corrupt".to_string() - )); - }; - - let persisted_beacon_chain = PersistedBeaconChain { - genesis_block_root: persisted_beacon_chain_v22.genesis_block_root, - }; - - let mut ops = vec![persisted_beacon_chain.as_kv_store_op(BEACON_CHAIN_DB_KEY)]; - - // 2) Wipe out all state temporary flags. While un-used in V23, if there's a rollback we could - // end-up with an inconsistent DB. - for state_root_result in db - .hot_db - .iter_column_keys::<Hash256>(DBColumn::BeaconStateTemporary) - { - let state_root = state_root_result?; - debug!( - ?state_root, - "Deleting temporary state on v23 schema migration" - ); - ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconStateTemporary, - state_root.as_slice().to_vec(), - )); - - // We also delete the temporary states themselves. Although there are known issue with - // temporary states and this could lead to DB corruption, we will only corrupt the DB in - // cases where the DB would be corrupted by restarting on v7.0.x. We consider these DBs - // "too far gone". Deleting here has the advantage of not generating warnings about - // disjoint state DAGs in the v24 upgrade, or the first pruning after migration. - ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconState, - state_root.as_slice().to_vec(), - )); - ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconStateSummary, - state_root.as_slice().to_vec(), - )); - } - - Ok(ops) -} - -pub fn downgrade_from_v23<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let Some(persisted_beacon_chain) = db.get_item::<PersistedBeaconChain>(&BEACON_CHAIN_DB_KEY)? - else { - // The `PersistedBeaconChain` must exist if fork choice exists. - return Err(Error::MigrationError( - "No persisted beacon chain found in DB. Datadir could be incorrect or DB could be corrupt".to_string(), - )); - }; - - // Recreate head-tracker from fork choice. - let Some(persisted_fork_choice) = db.get_item::<PersistedForkChoiceV17>(&FORK_CHOICE_DB_KEY)? - else { - // Fork choice should exist if the database exists. - return Err(Error::MigrationError( - "No fork choice found in DB".to_string(), - )); - }; - - // We use dummy roots for the justified states because we can source the balances from the v17 - // persited fork choice. The justified state root isn't required to look up the justified state's - // balances (as it would be in V28). This fork choice object with corrupt state roots SHOULD NOT - // be written to disk. - let dummy_justified_state_root = Hash256::repeat_byte(0x66); - let dummy_unrealized_justified_state_root = Hash256::repeat_byte(0x77); - - let fc_store = BeaconForkChoiceStore::from_persisted_v17( - persisted_fork_choice.fork_choice_store_v17, - dummy_justified_state_root, - dummy_unrealized_justified_state_root, - db.clone(), - ) - .map_err(|e| { - Error::MigrationError(format!( - "Error loading fork choice store from persisted: {e:?}" - )) - })?; - - // Doesn't matter what policy we use for invalid payloads, as our head calculation just - // considers descent from finalization. - let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let fork_choice = ForkChoice::from_persisted( - persisted_fork_choice.fork_choice_v17.try_into()?, - reset_payload_statuses, - fc_store, - &db.spec, - ) - .map_err(|e| { - Error::MigrationError(format!("Error loading fork choice from persisted: {e:?}")) - })?; - - let heads = fork_choice - .proto_array() - .heads_descended_from_finalization::<T::EthSpec>(fork_choice.finalized_checkpoint()); - - let head_roots = heads.iter().map(|node| node.root).collect(); - let head_slots = heads.iter().map(|node| node.slot).collect(); - - let persisted_beacon_chain_v22 = PersistedBeaconChainV22 { - _canonical_head_block_root: DUMMY_CANONICAL_HEAD_BLOCK_ROOT, - genesis_block_root: persisted_beacon_chain.genesis_block_root, - ssz_head_tracker: SszHeadTracker { - roots: head_roots, - slots: head_slots, - }, - }; - - let ops = vec![persisted_beacon_chain_v22.as_kv_store_op(BEACON_CHAIN_DB_KEY)]; - - Ok(ops) -} - -/// Helper struct that is used to encode/decode the state of the `HeadTracker` as SSZ bytes. -/// -/// This is used when persisting the state of the `BeaconChain` to disk. -#[derive(Encode, Decode, Clone)] -pub struct SszHeadTracker { - roots: Vec<Hash256>, - slots: Vec<Slot>, -} - -#[derive(Clone, Encode, Decode)] -pub struct PersistedBeaconChainV22 { - /// This value is ignored to resolve the issue described here: - /// - /// https://github.com/sigp/lighthouse/pull/1639 - /// - /// Its removal is tracked here: - /// - /// https://github.com/sigp/lighthouse/issues/1784 - pub _canonical_head_block_root: Hash256, - pub genesis_block_root: Hash256, - /// DEPRECATED - pub ssz_head_tracker: SszHeadTracker, -} - -impl StoreItem for PersistedBeaconChainV22 { - fn db_column() -> DBColumn { - DBColumn::BeaconChain - } - - fn as_store_bytes(&self) -> Vec<u8> { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result<Self, StoreError> { - Self::from_ssz_bytes(bytes).map_err(Into::into) - } -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs deleted file mode 100644 index 1e1823a836..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs +++ /dev/null @@ -1,605 +0,0 @@ -use crate::{ - beacon_chain::BeaconChainTypes, - summaries_dag::{DAGStateSummary, DAGStateSummaryV22, StateSummariesDAG}, -}; -use ssz::{Decode, DecodeError, Encode}; -use ssz_derive::Encode; -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; -use store::{ - DBColumn, Error, HotColdDB, HotStateSummary, KeyValueStore, KeyValueStoreOp, StoreItem, - hdiff::StorageStrategy, - hot_cold_store::{HotStateSummaryV22, OptionalDiffBaseState}, -}; -use tracing::{debug, info, warn}; -use types::{ - BeaconState, CACHED_EPOCHS, ChainSpec, Checkpoint, CommitteeCache, EthSpec, Hash256, Slot, -}; - -/// We stopped using the pruning checkpoint in schema v23 but never explicitly deleted it. -/// -/// We delete it as part of the v24 migration. -pub const PRUNING_CHECKPOINT_KEY: Hash256 = Hash256::repeat_byte(3); - -pub fn store_full_state_v22<E: EthSpec>( - state_root: &Hash256, - state: &BeaconState<E>, - ops: &mut Vec<KeyValueStoreOp>, -) -> Result<(), Error> { - let bytes = StorageContainer::new(state).as_ssz_bytes(); - ops.push(KeyValueStoreOp::PutKeyValue( - DBColumn::BeaconState, - state_root.as_slice().to_vec(), - bytes, - )); - Ok(()) -} - -/// Fetch a V22 state from the database either as a full state or using block replay. -pub fn get_state_v22<T: BeaconChainTypes>( - db: &Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, - state_root: &Hash256, - spec: &ChainSpec, -) -> Result<Option<BeaconState<T::EthSpec>>, Error> { - let Some(summary) = db.get_item::<HotStateSummaryV22>(state_root)? else { - return Ok(None); - }; - let Some(base_state) = - get_full_state_v22(&db.hot_db, &summary.epoch_boundary_state_root, spec)? - else { - return Ok(None); - }; - // Loading hot states via block replay doesn't care about the schema version, so we can use - // the DB's current method for this. - let update_cache = false; - db.load_hot_state_using_replay( - base_state, - summary.slot, - summary.latest_block_root, - update_cache, - ) - .map(Some) -} - -pub fn get_full_state_v22<KV: KeyValueStore<E>, E: EthSpec>( - db: &KV, - state_root: &Hash256, - spec: &ChainSpec, -) -> Result<Option<BeaconState<E>>, Error> { - match db.get_bytes(DBColumn::BeaconState, state_root.as_slice())? { - Some(bytes) => { - let container = StorageContainer::from_ssz_bytes(&bytes, spec)?; - Ok(Some(container.try_into()?)) - } - None => Ok(None), - } -} - -/// A container for storing `BeaconState` components. -/// -/// DEPRECATED. -#[derive(Encode)] -pub struct StorageContainer<E: EthSpec> { - state: BeaconState<E>, - committee_caches: Vec<Arc<CommitteeCache>>, -} - -impl<E: EthSpec> StorageContainer<E> { - /// Create a new instance for storing a `BeaconState`. - pub fn new(state: &BeaconState<E>) -> Self { - Self { - state: state.clone(), - committee_caches: state.committee_caches().to_vec(), - } - } - - pub fn from_ssz_bytes(bytes: &[u8], spec: &ChainSpec) -> Result<Self, ssz::DecodeError> { - // We need to use the slot-switching `from_ssz_bytes` of `BeaconState`, which doesn't - // compose with the other SSZ utils, so we duplicate some parts of `ssz_derive` here. - let mut builder = ssz::SszDecoderBuilder::new(bytes); - - builder.register_anonymous_variable_length_item()?; - builder.register_type::<Vec<CommitteeCache>>()?; - - let mut decoder = builder.build()?; - - let state = decoder.decode_next_with(|bytes| BeaconState::from_ssz_bytes(bytes, spec))?; - let committee_caches = decoder.decode_next()?; - - Ok(Self { - state, - committee_caches, - }) - } -} - -impl<E: EthSpec> TryInto<BeaconState<E>> for StorageContainer<E> { - type Error = Error; - - fn try_into(mut self) -> Result<BeaconState<E>, Error> { - let mut state = self.state; - - for i in (0..CACHED_EPOCHS).rev() { - if i >= self.committee_caches.len() { - return Err(Error::SszDecodeError(DecodeError::BytesInvalid( - "Insufficient committees for BeaconState".to_string(), - ))); - }; - - state.committee_caches_mut()[i] = self.committee_caches.remove(i); - } - - Ok(state) - } -} - -/// The checkpoint used for pruning the database. -/// -/// Updated whenever pruning is successful. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PruningCheckpoint { - pub checkpoint: Checkpoint, -} - -impl StoreItem for PruningCheckpoint { - fn db_column() -> DBColumn { - DBColumn::BeaconMeta - } - - fn as_store_bytes(&self) -> Vec<u8> { - self.checkpoint.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result<Self, Error> { - Ok(PruningCheckpoint { - checkpoint: Checkpoint::from_ssz_bytes(bytes)?, - }) - } -} - -pub fn upgrade_to_v24<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let mut migrate_ops = vec![]; - let split = db.get_split_info(); - let hot_hdiff_start_slot = split.slot; - - // Delete the `PruningCheckpoint` (no longer used). - migrate_ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconMeta, - PRUNING_CHECKPOINT_KEY.as_slice().to_vec(), - )); - - // Sanity check to make sure the HDiff grid is aligned with the epoch start - if hot_hdiff_start_slot % T::EthSpec::slots_per_epoch() != 0 { - return Err(Error::MigrationError(format!( - "hot_hdiff_start_slot is not first slot in epoch {hot_hdiff_start_slot}" - ))); - } - - // After V24 hot tree states, the in-memory `anchor_info.anchor_slot` is the start slot of the - // hot HDiff grid. Before the migration, it's set to the slot of the anchor state in the DB: - // - the genesis state on a genesis sync, or - // - the checkpoint state on a checkpoint sync. - // - // If the node has been running for a while the `anchor_slot` might be less than the finalized - // checkpoint. This upgrade constructs a grid only with unfinalized states, rooted in the - // current finalized state. So we set the `anchor_slot` to `split.slot` to root the grid in the - // current finalized state. Each migration sets the split to - // ``` - // Split { slot: finalized_state.slot(), state_root: finalized_state_root } - // ``` - { - let anchor_info = db.get_anchor_info(); - - // If the node is already an archive node, we can set the anchor slot to 0 and copy - // snapshots and diffs from the freezer DB to the hot DB in order to establish an initial - // hot grid that is aligned/"perfect" (no `start_slot`/`anchor_slot` to worry about). - // - // This only works if all of the following are true: - // - // - We have the previous snapshot for the split state stored in the freezer DB, i.e. - // if `previous_snapshot_slot >= state_upper_limit`. - // - The split state itself will be stored as a diff or snapshot in the new grid. We choose - // not to support a split state that requires block replay, because computing its previous - // state root from the DAG is not straight-forward. - let dummy_start_slot = Slot::new(0); - let closest_layer_points = db - .hierarchy - .closest_layer_points(split.slot, dummy_start_slot); - - let previous_snapshot_slot = - closest_layer_points - .iter() - .copied() - .min() - .ok_or(Error::MigrationError( - "closest_layer_points must not be empty".to_string(), - ))?; - - if previous_snapshot_slot >= anchor_info.state_upper_limit - && db - .hierarchy - .storage_strategy(split.slot, dummy_start_slot) - .is_ok_and(|strategy| !strategy.is_replay_from()) - { - info!( - %previous_snapshot_slot, - split_slot = %split.slot, - "Aligning hot diff grid to freezer" - ); - - // Set anchor slot to 0 in case it was set to something else by a previous checkpoint - // sync. - let mut new_anchor_info = anchor_info.clone(); - new_anchor_info.anchor_slot = Slot::new(0); - - // Update the anchor on disk atomically if migration is successful - migrate_ops.push(db.compare_and_set_anchor_info(anchor_info, new_anchor_info)?); - - // Copy each of the freezer layers to the hot DB in slot ascending order. - for layer_slot in closest_layer_points.into_iter().rev() { - // Do not try to load the split state itself from the freezer, it won't be there. - // It will be migrated in the main loop below. - if layer_slot == split.slot { - continue; - } - - let mut freezer_state = db.load_cold_state_by_slot(layer_slot)?; - - let state_root = freezer_state.canonical_root()?; - - let mut state_ops = vec![]; - db.store_hot_state(&state_root, &freezer_state, &mut state_ops)?; - db.hot_db.do_atomically(state_ops)?; - } - } else { - // Otherwise for non-archive nodes, set the anchor slot for the hot grid to the current - // split slot (the oldest slot available). - let mut new_anchor_info = anchor_info.clone(); - new_anchor_info.anchor_slot = hot_hdiff_start_slot; - - // Update the anchor in disk atomically if migration is successful - migrate_ops.push(db.compare_and_set_anchor_info(anchor_info, new_anchor_info)?); - } - } - - let state_summaries_dag = new_dag::<T>(&db)?; - - // We compute the state summaries DAG outside of a DB migration. Therefore if the DB is properly - // prunned, it should have a single root equal to the split. - let state_summaries_dag_roots = state_summaries_dag.tree_roots(); - if state_summaries_dag_roots.len() == 1 { - let (root_summary_state_root, root_summary) = - state_summaries_dag_roots.first().expect("len == 1"); - if *root_summary_state_root != split.state_root { - warn!( - ?root_summary_state_root, - ?root_summary, - ?split, - "State summaries DAG root is not the split" - ); - } - } else { - warn!( - location = "migration", - state_summaries_dag_roots = ?state_summaries_dag_roots, - "State summaries DAG found more than one root" - ); - } - - // Sort summaries by slot so we have their ancestor diffs already stored when we store them. - // If the summaries are sorted topologically we can insert them into the DB like if they were a - // new state, re-using existing code. As states are likely to be sequential the diff cache - // should kick in making the migration more efficient. If we just iterate the column of - // summaries we may get distance state of each iteration. - let summaries_by_slot = state_summaries_dag.summaries_by_slot_ascending(); - debug!( - summaries_count = state_summaries_dag.summaries_count(), - slots_count = summaries_by_slot.len(), - min_slot = ?summaries_by_slot.first_key_value().map(|(slot, _)| slot), - max_slot = ?summaries_by_slot.last_key_value().map(|(slot, _)| slot), - ?state_summaries_dag_roots, - %hot_hdiff_start_slot, - split_state_root = ?split.state_root, - "Starting hot states migration" - ); - - // Upgrade all hot DB state summaries to the new type: - // - Set all summaries of boundary states to `Snapshot` type - // - Set all others to `Replay` pointing to `epoch_boundary_state_root` - - let mut diffs_written = 0; - let mut summaries_written = 0; - let mut last_log_time = Instant::now(); - - for (slot, old_hot_state_summaries) in summaries_by_slot { - for (state_root, old_summary) in old_hot_state_summaries { - if slot < hot_hdiff_start_slot { - // To reach here, there must be some pruning issue with the DB where we still have - // hot states below the split slot. This states can't be migrated as we can't compute - // a storage strategy for them. After this if else block, the summary and state are - // scheduled for deletion. - debug!( - %slot, - ?state_root, - "Ignoring state summary prior to split slot" - ); - } else { - // 1. Store snapshot or diff at this slot (if required). - let storage_strategy = db.hot_storage_strategy(slot)?; - debug!( - %slot, - ?state_root, - ?storage_strategy, - "Migrating state summary" - ); - - match storage_strategy { - StorageStrategy::DiffFrom(_) | StorageStrategy::Snapshot => { - // Load the state and re-store it as a snapshot or diff. - let state = get_state_v22::<T>(&db, &state_root, &db.spec)? - .ok_or(Error::MissingState(state_root))?; - - // Store immediately so that future diffs can load and diff from it. - let mut ops = vec![]; - // We must commit the hot state summary immediately, otherwise we can't diff - // against it and future writes will fail. That's why we write the new hot - // summaries in a different column to have both new and old data present at - // once. Otherwise if the process crashes during the migration the database will - // be broken. - db.store_hot_state_summary(&state_root, &state, &mut ops)?; - db.store_hot_state_diffs(&state_root, &state, &mut ops)?; - db.hot_db.do_atomically(ops)?; - diffs_written += 1; - } - StorageStrategy::ReplayFrom(diff_base_slot) => { - // Optimization: instead of having to load the state of each summary we load x32 - // less states by manually computing the HotStateSummary roots using the - // computed state dag. - // - // No need to store diffs for states that will be reconstructed by replaying - // blocks. - // - // 2. Convert the summary to the new format. - if state_root == split.state_root { - return Err(Error::MigrationError( - "unreachable: split state should be stored as a snapshot or diff" - .to_string(), - )); - } - let previous_state_root = state_summaries_dag - .previous_state_root(state_root) - .map_err(|e| { - Error::MigrationError(format!( - "error computing previous_state_root {e:?}" - )) - })?; - - let diff_base_state = OptionalDiffBaseState::new( - diff_base_slot, - state_summaries_dag - .ancestor_state_root_at_slot(state_root, diff_base_slot) - .map_err(|e| { - Error::MigrationError(format!( - "error computing ancestor_state_root_at_slot \ - ({state_root:?}, {diff_base_slot}): {e:?}" - )) - })?, - ); - - let new_summary = HotStateSummary { - slot, - latest_block_root: old_summary.latest_block_root, - latest_block_slot: old_summary.latest_block_slot, - previous_state_root, - diff_base_state, - }; - let op = new_summary.as_kv_store_op(state_root); - // It's not necessary to immediately commit the summaries of states that are - // ReplayFrom. However we do so for simplicity. - db.hot_db.do_atomically(vec![op])?; - } - } - } - - // 3. Stage old data for deletion. - if slot % T::EthSpec::slots_per_epoch() == 0 { - migrate_ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconState, - state_root.as_slice().to_vec(), - )); - } - - // Delete previous summaries - migrate_ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconStateSummary, - state_root.as_slice().to_vec(), - )); - - summaries_written += 1; - if last_log_time.elapsed() > Duration::from_secs(5) { - last_log_time = Instant::now(); - info!( - diffs_written, - summaries_written, - summaries_count = state_summaries_dag.summaries_count(), - "Hot states migration in progress" - ); - } - } - } - - info!( - diffs_written, - summaries_written, - summaries_count = state_summaries_dag.summaries_count(), - "Hot states migration complete" - ); - - Ok(migrate_ops) -} - -pub fn downgrade_from_v24<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let state_summaries = db - .load_hot_state_summaries()? - .into_iter() - .map(|(state_root, summary)| (state_root, summary.into())) - .collect::<Vec<(Hash256, DAGStateSummary)>>(); - - info!( - summaries_count = state_summaries.len(), - "DB downgrade of v24 state summaries started" - ); - - let state_summaries_dag = StateSummariesDAG::new(state_summaries) - .map_err(|e| Error::MigrationError(format!("Error on new StateSumariesDAG {e:?}")))?; - - let mut migrate_ops = vec![]; - let mut states_written = 0; - let mut summaries_written = 0; - let mut summaries_skipped = 0; - let mut last_log_time = Instant::now(); - - // Rebuild the PruningCheckpoint from the split. - let split = db.get_split_info(); - let pruning_checkpoint = PruningCheckpoint { - checkpoint: Checkpoint { - epoch: split.slot.epoch(T::EthSpec::slots_per_epoch()), - root: split.block_root, - }, - }; - migrate_ops.push(pruning_checkpoint.as_kv_store_op(PRUNING_CHECKPOINT_KEY)); - - // Convert state summaries back to the old format. - for (state_root, summary) in state_summaries_dag - .summaries_by_slot_ascending() - .into_iter() - .flat_map(|(_, summaries)| summaries) - { - // No need to migrate any states prior to the split. The v22 schema does not need them, and - // they would generate warnings about a disjoint DAG when re-upgrading to V24. - if summary.slot < split.slot { - debug!( - slot = %summary.slot, - ?state_root, - "Skipping migration of pre-split state" - ); - summaries_skipped += 1; - continue; - } - - // If boundary state: persist. - // Do not cache these states as they are unlikely to be relevant later. - let update_cache = false; - if summary.slot % T::EthSpec::slots_per_epoch() == 0 { - let (state, _) = db - .load_hot_state(&state_root, update_cache)? - .ok_or(Error::MissingState(state_root))?; - - // Immediately commit the state, so we don't OOM. It's stored in a different - // column so if the migration crashes we'll just store extra harmless junk in the DB. - let mut state_write_ops = vec![]; - store_full_state_v22(&state_root, &state, &mut state_write_ops)?; - db.hot_db.do_atomically(state_write_ops)?; - states_written += 1; - } - - // Persist old summary. - let epoch_boundary_state_slot = summary.slot - summary.slot % T::EthSpec::slots_per_epoch(); - let old_summary = HotStateSummaryV22 { - slot: summary.slot, - latest_block_root: summary.latest_block_root, - epoch_boundary_state_root: state_summaries_dag - .ancestor_state_root_at_slot(state_root, epoch_boundary_state_slot) - .map_err(|e| { - Error::MigrationError(format!( - "error computing ancestor_state_root_at_slot({state_root:?}, {epoch_boundary_state_slot}) {e:?}" - )) - })?, - }; - migrate_ops.push(KeyValueStoreOp::PutKeyValue( - DBColumn::BeaconStateSummary, - state_root.as_slice().to_vec(), - old_summary.as_ssz_bytes(), - )); - summaries_written += 1; - - if last_log_time.elapsed() > Duration::from_secs(5) { - last_log_time = Instant::now(); - info!( - states_written, - summaries_written, - summaries_count = state_summaries_dag.summaries_count(), - "DB downgrade of v24 state summaries in progress" - ); - } - } - - // Delete all V24 schema data. We do this outside the loop over summaries to ensure we cover - // every piece of data and to simplify logic around skipping certain summaries that do not get - // migrated. - for db_column in [ - DBColumn::BeaconStateHotSummary, - DBColumn::BeaconStateHotDiff, - DBColumn::BeaconStateHotSnapshot, - ] { - for key in db.hot_db.iter_column_keys::<Hash256>(db_column) { - let state_root = key?; - migrate_ops.push(KeyValueStoreOp::DeleteKey( - db_column, - state_root.as_slice().to_vec(), - )); - } - } - - info!( - states_written, - summaries_written, - summaries_skipped, - summaries_count = state_summaries_dag.summaries_count(), - "DB downgrade of v24 state summaries completed" - ); - - Ok(migrate_ops) -} - -fn new_dag<T: BeaconChainTypes>( - db: &HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>, -) -> Result<StateSummariesDAG, Error> { - // Collect all sumaries for unfinalized states - let state_summaries_v22 = db - .hot_db - // Collect summaries from the legacy V22 column BeaconStateSummary - .iter_column::<Hash256>(DBColumn::BeaconStateSummary) - .map(|res| { - let (key, value) = res?; - let state_root: Hash256 = key; - let summary = HotStateSummaryV22::from_ssz_bytes(&value)?; - let block_root = summary.latest_block_root; - // Read blocks to get the block slot and parent root. In Holesky forced finalization it - // took 5100 ms to read 15072 state summaries, so it's not really necessary to - // de-duplicate block reads. - let block = db - .get_blinded_block(&block_root)? - .ok_or(Error::MissingBlock(block_root))?; - - Ok(( - state_root, - DAGStateSummaryV22 { - slot: summary.slot, - latest_block_root: summary.latest_block_root, - block_slot: block.slot(), - block_parent_root: block.parent_root(), - }, - )) - }) - .collect::<Result<Vec<_>, Error>>()?; - - StateSummariesDAG::new_from_v22(state_summaries_v22) - .map_err(|e| Error::MigrationError(format!("error computing states summaries dag {e:?}"))) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs deleted file mode 100644 index 44e8894d6f..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs +++ /dev/null @@ -1,20 +0,0 @@ -use store::{DBColumn, Error, KeyValueStoreOp}; -use tracing::info; -use types::Hash256; - -pub const ETH1_CACHE_DB_KEY: Hash256 = Hash256::ZERO; - -/// Delete the on-disk eth1 data. -pub fn upgrade_to_v25() -> Result<Vec<KeyValueStoreOp>, Error> { - info!("Deleting eth1 data from disk for v25 DB upgrade"); - Ok(vec![KeyValueStoreOp::DeleteKey( - DBColumn::Eth1Cache, - ETH1_CACHE_DB_KEY.as_slice().to_vec(), - )]) -} - -/// No-op: we don't need to recreate on-disk eth1 data, as previous versions gracefully handle -/// data missing from disk. -pub fn downgrade_from_v25() -> Result<Vec<KeyValueStoreOp>, Error> { - Ok(vec![]) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs deleted file mode 100644 index 38714ea060..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::BeaconChainTypes; -use crate::custody_context::CustodyContextSsz; -use crate::persisted_custody::{CUSTODY_DB_KEY, PersistedCustody}; -use ssz::{Decode, Encode}; -use ssz_derive::{Decode, Encode}; -use std::sync::Arc; -use store::{DBColumn, Error, HotColdDB, KeyValueStoreOp, StoreItem}; -use tracing::info; - -#[derive(Debug, Encode, Decode, Clone)] -pub(crate) struct CustodyContextSszV24 { - pub(crate) validator_custody_at_head: u64, - pub(crate) persisted_is_supernode: bool, -} - -pub(crate) struct PersistedCustodyV24(CustodyContextSszV24); - -impl StoreItem for PersistedCustodyV24 { - fn db_column() -> DBColumn { - DBColumn::CustodyContext - } - - fn as_store_bytes(&self) -> Vec<u8> { - self.0.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result<Self, Error> { - let custody_context = CustodyContextSszV24::from_ssz_bytes(bytes)?; - Ok(PersistedCustodyV24(custody_context)) - } -} - -/// Upgrade the `CustodyContext` entry to v26. -pub fn upgrade_to_v26<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let ops = if db.spec.is_peer_das_scheduled() { - match db.get_item::<PersistedCustodyV24>(&CUSTODY_DB_KEY) { - Ok(Some(PersistedCustodyV24(ssz_v24))) => { - info!("Migrating `CustodyContext` to v26 schema"); - let custody_context_v2 = CustodyContextSsz { - validator_custody_at_head: ssz_v24.validator_custody_at_head, - persisted_is_supernode: ssz_v24.persisted_is_supernode, - epoch_validator_custody_requirements: vec![], - }; - vec![KeyValueStoreOp::PutKeyValue( - DBColumn::CustodyContext, - CUSTODY_DB_KEY.as_slice().to_vec(), - PersistedCustody(custody_context_v2).as_store_bytes(), - )] - } - _ => { - vec![] - } - } - } else { - // Delete it from db if PeerDAS hasn't been scheduled - vec![KeyValueStoreOp::DeleteKey( - DBColumn::CustodyContext, - CUSTODY_DB_KEY.as_slice().to_vec(), - )] - }; - - Ok(ops) -} - -pub fn downgrade_from_v26<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let res = db.get_item::<PersistedCustody>(&CUSTODY_DB_KEY); - let ops = match res { - Ok(Some(PersistedCustody(ssz_v26))) => { - info!("Migrating `CustodyContext` back from v26 schema"); - let custody_context_v24 = CustodyContextSszV24 { - validator_custody_at_head: ssz_v26.validator_custody_at_head, - persisted_is_supernode: ssz_v26.persisted_is_supernode, - }; - vec![KeyValueStoreOp::PutKeyValue( - DBColumn::CustodyContext, - CUSTODY_DB_KEY.as_slice().to_vec(), - PersistedCustodyV24(custody_context_v24).as_store_bytes(), - )] - } - _ => { - // no op if it's not on the db, as previous versions gracefully handle data missing from disk. - vec![] - } - }; - - Ok(ops) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs deleted file mode 100644 index fbe865ee27..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::BeaconChainTypes; -use std::sync::Arc; -use store::{Error, HotColdDB, metadata::SchemaVersion}; - -/// Add `DataColumnCustodyInfo` entry to v27. -pub fn upgrade_to_v27<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<(), Error> { - if db.spec.is_peer_das_scheduled() { - db.put_data_column_custody_info(None)?; - db.store_schema_version_atomically(SchemaVersion(27), vec![])?; - } - - Ok(()) -} - -pub fn downgrade_from_v27<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<(), Error> { - if db.spec.is_peer_das_scheduled() { - return Err(Error::MigrationError( - "Cannot downgrade from v27 if peerDAS is scheduled".to_string(), - )); - } - Ok(()) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs deleted file mode 100644 index 5885eaabc0..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs +++ /dev/null @@ -1,152 +0,0 @@ -use crate::{ - BeaconChain, BeaconChainTypes, BeaconForkChoiceStore, PersistedForkChoiceStoreV17, - beacon_chain::FORK_CHOICE_DB_KEY, - persisted_fork_choice::{PersistedForkChoiceV17, PersistedForkChoiceV28}, - summaries_dag::{DAGStateSummary, StateSummariesDAG}, -}; -use fork_choice::{ForkChoice, ForkChoiceStore, ResetPayloadStatuses}; -use std::sync::Arc; -use store::{Error, HotColdDB, KeyValueStoreOp, StoreItem}; -use tracing::{info, warn}; -use types::{EthSpec, Hash256}; - -/// Upgrade `PersistedForkChoice` from V17 to V28. -pub fn upgrade_to_v28<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let Some(persisted_fork_choice_v17) = - db.get_item::<PersistedForkChoiceV17>(&FORK_CHOICE_DB_KEY)? - else { - warn!("No fork choice found to upgrade to v28"); - return Ok(vec![]); - }; - - // Load state DAG in order to compute justified checkpoint roots. - let state_summaries_dag = { - let state_summaries = db - .load_hot_state_summaries()? - .into_iter() - .map(|(state_root, summary)| (state_root, summary.into())) - .collect::<Vec<(Hash256, DAGStateSummary)>>(); - - StateSummariesDAG::new(state_summaries).map_err(|e| { - Error::MigrationError(format!("Error loading state summaries DAG: {e:?}")) - })? - }; - - // Determine the justified state roots. - let justified_checkpoint = persisted_fork_choice_v17 - .fork_choice_store_v17 - .justified_checkpoint; - let justified_block_root = justified_checkpoint.root; - let justified_slot = justified_checkpoint - .epoch - .start_slot(T::EthSpec::slots_per_epoch()); - let justified_state_root = state_summaries_dag - .state_root_at_slot(justified_block_root, justified_slot) - .ok_or_else(|| { - Error::MigrationError(format!( - "Missing state root for justified slot {justified_slot} with latest_block_root \ - {justified_block_root:?}" - )) - })?; - - let unrealized_justified_checkpoint = persisted_fork_choice_v17 - .fork_choice_store_v17 - .unrealized_justified_checkpoint; - let unrealized_justified_block_root = unrealized_justified_checkpoint.root; - let unrealized_justified_slot = unrealized_justified_checkpoint - .epoch - .start_slot(T::EthSpec::slots_per_epoch()); - let unrealized_justified_state_root = state_summaries_dag - .state_root_at_slot(unrealized_justified_block_root, unrealized_justified_slot) - .ok_or_else(|| { - Error::MigrationError(format!( - "Missing state root for unrealized justified slot {unrealized_justified_slot} \ - with latest_block_root {unrealized_justified_block_root:?}" - )) - })?; - - let fc_store = BeaconForkChoiceStore::from_persisted_v17( - persisted_fork_choice_v17.fork_choice_store_v17, - justified_state_root, - unrealized_justified_state_root, - db.clone(), - ) - .map_err(|e| { - Error::MigrationError(format!( - "Error loading fork choice store from persisted: {e:?}" - )) - })?; - - info!( - ?justified_state_root, - %justified_slot, - "Added justified state root to fork choice" - ); - - // Construct top-level ForkChoice struct using the patched fork choice store, and the converted - // proto array. - let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let fork_choice = ForkChoice::from_persisted( - persisted_fork_choice_v17.fork_choice_v17.try_into()?, - reset_payload_statuses, - fc_store, - db.get_chain_spec(), - ) - .map_err(|e| Error::MigrationError(format!("Unable to build ForkChoice: {e:?}")))?; - - let ops = vec![BeaconChain::<T>::persist_fork_choice_in_batch_standalone( - &fork_choice, - db.get_config(), - )?]; - - info!("Upgraded fork choice for DB schema v28"); - - Ok(ops) -} - -pub fn downgrade_from_v28<T: BeaconChainTypes>( - db: Arc<HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>>, -) -> Result<Vec<KeyValueStoreOp>, Error> { - let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let Some(fork_choice) = - BeaconChain::<T>::load_fork_choice(db.clone(), reset_payload_statuses, db.get_chain_spec()) - .map_err(|e| Error::MigrationError(format!("Unable to load fork choice: {e:?}")))? - else { - warn!("No fork choice to downgrade"); - return Ok(vec![]); - }; - - // Recreate V28 persisted fork choice, then convert each field back to its V17 version. - let persisted_fork_choice = PersistedForkChoiceV28 { - fork_choice: fork_choice.to_persisted(), - fork_choice_store: fork_choice.fc_store().to_persisted(), - }; - - let justified_balances = fork_choice.fc_store().justified_balances(); - - // 1. Create `proto_array::PersistedForkChoiceV17`. - let fork_choice_v17: fork_choice::PersistedForkChoiceV17 = ( - persisted_fork_choice.fork_choice, - justified_balances.clone(), - ) - .into(); - - let fork_choice_store_v17: PersistedForkChoiceStoreV17 = ( - persisted_fork_choice.fork_choice_store, - justified_balances.clone(), - ) - .into(); - - let persisted_fork_choice_v17 = PersistedForkChoiceV17 { - fork_choice_v17, - fork_choice_store_v17, - }; - - let ops = vec![persisted_fork_choice_v17.as_kv_store_op(FORK_CHOICE_DB_KEY)]; - - info!("Downgraded fork choice for DB schema v28"); - - Ok(ops) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs new file mode 100644 index 0000000000..77d4be3443 --- /dev/null +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs @@ -0,0 +1,151 @@ +use crate::beacon_chain::{BeaconChainTypes, FORK_CHOICE_DB_KEY}; +use crate::persisted_fork_choice::{PersistedForkChoiceV28, PersistedForkChoiceV29}; +use std::collections::HashMap; +use store::hot_cold_store::HotColdDB; +use store::{DBColumn, Error as StoreError, KeyValueStore, KeyValueStoreOp}; +use tracing::warn; +use types::EthSpec; + +/// Upgrade from schema v28 to v29. +/// +/// - Clears `best_child` and `best_descendant` on all nodes (replaced by +/// virtual tree walk). +/// - Fails if the persisted fork choice contains any V17 (pre-Gloas) proto +/// nodes at or after the Gloas fork slot. +/// +/// Returns a list of store ops to be applied atomically with the schema version write. +pub fn upgrade_to_v29<T: BeaconChainTypes>( + db: &HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>, +) -> Result<Vec<KeyValueStoreOp>, StoreError> { + let gloas_fork_slot = db + .spec + .gloas_fork_epoch + .map(|epoch| epoch.start_slot(T::EthSpec::slots_per_epoch())); + + // Load the persisted fork choice (v28 format). + let Some(fc_bytes) = db + .hot_db + .get_bytes(DBColumn::ForkChoice, FORK_CHOICE_DB_KEY.as_slice())? + else { + return Ok(vec![]); + }; + + let persisted_v28 = PersistedForkChoiceV28::from_bytes(&fc_bytes, db.get_config())?; + + // Check for V17 nodes at/after the Gloas fork slot. + if let Some(gloas_fork_slot) = gloas_fork_slot { + let bad_node = persisted_v28 + .fork_choice_v28 + .proto_array_v28 + .nodes + .iter() + .find(|node| node.slot >= gloas_fork_slot); + + if let Some(node) = bad_node { + return Err(StoreError::MigrationError(format!( + "cannot upgrade from v28 to v29: found V17 proto node at slot {} (root: {:?}) \ + which is at or after the Gloas fork slot {}. This node has synced a chain with \ + Gloas disabled and cannot be upgraded. Please resync from scratch.", + node.slot, node.root, gloas_fork_slot, + ))); + } + } + + // Read the previous proposer boost before converting to V29 (V29 no longer stores it). + let previous_proposer_boost = persisted_v28 + .fork_choice_v28 + .proto_array_v28 + .previous_proposer_boost; + + // Convert to v29. + let mut persisted_v29 = PersistedForkChoiceV29::from(persisted_v28); + + // Subtract the proposer boost from the boosted node and all its ancestors. + // + // In the V28 schema, `apply_score_changes` baked the proposer boost directly into node + // weights and back-propagated it up the parent chain. In V29, the boost is computed + // on-the-fly during the virtual tree walk. If we don't subtract the baked-in boost here, + // it will be double-counted after the upgrade. + if !previous_proposer_boost.root.is_zero() && previous_proposer_boost.score > 0 { + let score = previous_proposer_boost.score; + let indices: HashMap<_, _> = persisted_v29 + .fork_choice + .proto_array + .indices + .iter() + .cloned() + .collect(); + + if let Some(node_index) = indices.get(&previous_proposer_boost.root).copied() { + let nodes = &mut persisted_v29.fork_choice.proto_array.nodes; + let mut current = Some(node_index); + while let Some(idx) = current { + if let Some(node) = nodes.get_mut(idx) { + *node.weight_mut() = node.weight().saturating_sub(score); + current = node.parent(); + } else { + break; + } + } + } else { + warn!( + root = ?previous_proposer_boost.root, + "Proposer boost node missing from fork choice" + ); + } + } + + Ok(vec![ + persisted_v29.as_kv_store_op(FORK_CHOICE_DB_KEY, db.get_config())?, + ]) +} + +/// Downgrade from schema v29 to v28. +/// +/// Converts the persisted fork choice from V29 format back to V28. +/// Fails if the persisted fork choice contains any V29 proto nodes, as these contain +/// payload-specific fields that cannot be losslessly converted back to V17 format. +/// +/// Returns a list of store ops to be applied atomically with the schema version write. +pub fn downgrade_from_v29<T: BeaconChainTypes>( + db: &HotColdDB<T::EthSpec, T::HotStore, T::ColdStore>, +) -> Result<Vec<KeyValueStoreOp>, StoreError> { + // Load the persisted fork choice (v29 format, compressed). + let Some(fc_bytes) = db + .hot_db + .get_bytes(DBColumn::ForkChoice, FORK_CHOICE_DB_KEY.as_slice())? + else { + return Ok(vec![]); + }; + + let persisted_v29 = + PersistedForkChoiceV29::from_bytes(&fc_bytes, db.get_config()).map_err(|e| { + StoreError::MigrationError(format!( + "cannot downgrade from v29 to v28: failed to decode fork choice: {:?}", + e + )) + })?; + + let has_v29_node = persisted_v29 + .fork_choice + .proto_array + .nodes + .iter() + .any(|node| matches!(node, proto_array::core::ProtoNode::V29(_))); + + if has_v29_node { + return Err(StoreError::MigrationError( + "cannot downgrade from v29 to v28: the persisted fork choice contains V29 proto \ + nodes which cannot be losslessly converted to V17 format. The Gloas-specific \ + payload data would be lost." + .to_string(), + )); + } + + // Convert to v28 and encode. + let persisted_v28 = PersistedForkChoiceV28::from(persisted_v29); + + Ok(vec![ + persisted_v28.as_kv_store_op(FORK_CHOICE_DB_KEY, db.get_config())?, + ]) +} diff --git a/beacon_node/beacon_chain/src/shuffling_cache.rs b/beacon_node/beacon_chain/src/shuffling_cache.rs index 618d459754..3d0fd80cf6 100644 --- a/beacon_node/beacon_chain/src/shuffling_cache.rs +++ b/beacon_node/beacon_chain/src/shuffling_cache.rs @@ -6,7 +6,7 @@ use oneshot_broadcast::{Receiver, Sender, oneshot}; use tracing::debug; use types::{ AttestationShufflingId, BeaconState, Epoch, EthSpec, Hash256, RelativeEpoch, - beacon_state::CommitteeCache, + state::CommitteeCache, }; use crate::{BeaconChainError, metrics}; diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index a070dc350b..cb916cb514 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -409,12 +409,6 @@ fn advance_head<T: BeaconChainTypes>(beacon_chain: &Arc<BeaconChain<T>>) -> Resu ); } - // Apply the state to the attester cache, if the cache deems it interesting. - beacon_chain - .attester_cache - .maybe_cache_state(&state, head_block_root, &beacon_chain.spec) - .map_err(BeaconChainError::from)?; - let final_slot = state.slot(); // If we have moved into the next slot whilst processing the state then this function is going diff --git a/beacon_node/beacon_chain/src/summaries_dag.rs b/beacon_node/beacon_chain/src/summaries_dag.rs index 4ddcdaab5a..50fc0b3820 100644 --- a/beacon_node/beacon_chain/src/summaries_dag.rs +++ b/beacon_node/beacon_chain/src/summaries_dag.rs @@ -14,14 +14,6 @@ pub struct DAGStateSummary { pub previous_state_root: Hash256, } -#[derive(Debug, Clone, Copy)] -pub struct DAGStateSummaryV22 { - pub slot: Slot, - pub latest_block_root: Hash256, - pub block_slot: Slot, - pub block_parent_root: Hash256, -} - pub struct StateSummariesDAG { // state_root -> state_summary state_summaries_by_state_root: HashMap<Hash256, DAGStateSummary>, @@ -40,10 +32,6 @@ pub enum Error { new_state_summary: (Slot, Hash256), }, MissingStateSummary(Hash256), - MissingStateSummaryByBlockRoot { - state_root: Hash256, - latest_block_root: Hash256, - }, MissingChildStateRoot(Hash256), RequestedSlotAboveSummary { starting_state_root: Hash256, @@ -109,89 +97,6 @@ impl StateSummariesDAG { }) } - /// Computes a DAG from a sequence of state summaries, including their parent block - /// relationships. - /// - /// - Expects summaries to be contiguous per slot: there must exist a summary at every slot - /// of each tree branch - /// - Maybe include multiple disjoint trees. The root of each tree will have a ZERO parent state - /// root, which will error later when calling `previous_state_root`. - pub fn new_from_v22( - state_summaries_v22: Vec<(Hash256, DAGStateSummaryV22)>, - ) -> Result<Self, Error> { - // Group them by latest block root, and sorted state slot - let mut state_summaries_by_block_root = HashMap::<_, BTreeMap<_, _>>::new(); - for (state_root, summary) in state_summaries_v22.iter() { - let summaries = state_summaries_by_block_root - .entry(summary.latest_block_root) - .or_default(); - - // Sanity check to ensure no duplicate summaries for the tuple (block_root, state_slot) - match summaries.entry(summary.slot) { - Entry::Vacant(entry) => { - entry.insert((state_root, summary)); - } - Entry::Occupied(existing) => { - return Err(Error::DuplicateStateSummary { - block_root: summary.latest_block_root, - existing_state_summary: (summary.slot, *state_root).into(), - new_state_summary: (*existing.key(), *existing.get().0), - }); - } - } - } - - let state_summaries = state_summaries_v22 - .iter() - .map(|(state_root, summary)| { - let previous_state_root = if summary.slot == 0 { - Hash256::ZERO - } else { - let previous_slot = summary.slot - 1; - - // Check the set of states in the same state's block root - let same_block_root_summaries = state_summaries_by_block_root - .get(&summary.latest_block_root) - // Should never error: we construct the HashMap here and must have at least - // one entry per block root - .ok_or(Error::MissingStateSummaryByBlockRoot { - state_root: *state_root, - latest_block_root: summary.latest_block_root, - })?; - if let Some((state_root, _)) = same_block_root_summaries.get(&previous_slot) { - // Skipped slot: block root at previous slot is the same as latest block root. - **state_root - } else { - // Common case: not a skipped slot. - // - // If we can't find a state summmary for the parent block and previous slot, - // then there is some amount of disjointedness in the DAG. We set the parent - // state root to 0x0 in this case, and will prune any dangling states. - let parent_block_root = summary.block_parent_root; - state_summaries_by_block_root - .get(&parent_block_root) - .and_then(|parent_block_summaries| { - parent_block_summaries.get(&previous_slot) - }) - .map_or(Hash256::ZERO, |(parent_state_root, _)| **parent_state_root) - } - }; - - Ok(( - *state_root, - DAGStateSummary { - slot: summary.slot, - latest_block_root: summary.latest_block_root, - latest_block_slot: summary.block_slot, - previous_state_root, - }, - )) - }) - .collect::<Result<Vec<_>, _>>()?; - - Self::new(state_summaries) - } - // Returns all non-unique latest block roots of a given set of states pub fn blocks_of_states<'a, I: Iterator<Item = &'a Hash256>>( &self, @@ -379,106 +284,3 @@ impl From<HotStateSummary> for DAGStateSummary { } } } - -#[cfg(test)] -mod tests { - use super::{DAGStateSummaryV22, Error, StateSummariesDAG}; - use bls::FixedBytesExtended; - use types::{Hash256, Slot}; - - fn root(n: u64) -> Hash256 { - Hash256::from_low_u64_le(n) - } - - #[test] - fn new_from_v22_empty() { - StateSummariesDAG::new_from_v22(vec![]).unwrap(); - } - - fn assert_previous_state_root_is_zero(dag: &StateSummariesDAG, root: Hash256) { - assert!(matches!( - dag.previous_state_root(root).unwrap_err(), - Error::RootUnknownPreviousStateRoot { .. } - )); - } - - #[test] - fn new_from_v22_one_state() { - let root_a = root(0xa); - let root_1 = root(1); - let root_2 = root(2); - let summary_1 = DAGStateSummaryV22 { - slot: Slot::new(1), - latest_block_root: root_1, - block_parent_root: root_2, - block_slot: Slot::new(1), - }; - - let dag = StateSummariesDAG::new_from_v22(vec![(root_a, summary_1)]).unwrap(); - - // The parent of the root summary is ZERO - assert_previous_state_root_is_zero(&dag, root_a); - } - - #[test] - fn new_from_v22_multiple_states() { - let dag = StateSummariesDAG::new_from_v22(vec![ - ( - root(0xa), - DAGStateSummaryV22 { - slot: Slot::new(3), - latest_block_root: root(3), - block_parent_root: root(1), - block_slot: Slot::new(3), - }, - ), - ( - root(0xb), - DAGStateSummaryV22 { - slot: Slot::new(4), - latest_block_root: root(4), - block_parent_root: root(3), - block_slot: Slot::new(4), - }, - ), - // fork 1 - ( - root(0xc), - DAGStateSummaryV22 { - slot: Slot::new(5), - latest_block_root: root(5), - block_parent_root: root(4), - block_slot: Slot::new(5), - }, - ), - // fork 2 - // skipped slot - ( - root(0xd), - DAGStateSummaryV22 { - slot: Slot::new(5), - latest_block_root: root(4), - block_parent_root: root(3), - block_slot: Slot::new(4), - }, - ), - // normal slot - ( - root(0xe), - DAGStateSummaryV22 { - slot: Slot::new(6), - latest_block_root: root(6), - block_parent_root: root(4), - block_slot: Slot::new(6), - }, - ), - ]) - .unwrap(); - - // The parent of the root summary is ZERO - assert_previous_state_root_is_zero(&dag, root(0xa)); - assert_eq!(dag.previous_state_root(root(0xc)).unwrap(), root(0xb)); - assert_eq!(dag.previous_state_root(root(0xd)).unwrap(), root(0xb)); - assert_eq!(dag.previous_state_root(root(0xe)).unwrap(), root(0xd)); - } -} diff --git a/beacon_node/beacon_chain/src/sync_committee_verification.rs b/beacon_node/beacon_chain/src/sync_committee_verification.rs index e74e284e58..2491e08eff 100644 --- a/beacon_node/beacon_chain/src/sync_committee_verification.rs +++ b/beacon_node/beacon_chain/src/sync_committee_verification.rs @@ -48,13 +48,13 @@ use strum::AsRefStr; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use types::ChainSpec; +use types::SlotData; use types::consts::altair::SYNC_COMMITTEE_SUBNET_COUNT; -use types::slot_data::SlotData; use types::sync_committee::SyncCommitteeError; use types::{ BeaconStateError, EthSpec, Hash256, SignedContributionAndProof, Slot, SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, - sync_committee_contribution::Error as ContributionError, + sync_committee::SyncCommitteeContributionError as ContributionError, }; /// Returned when a sync committee contribution was not successfully verified. It might not have been verified for diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 2833c1aecd..ffd27895cf 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1,12 +1,12 @@ use crate::blob_verification::GossipVerifiedBlob; -use crate::block_verification_types::{AsBlock, RpcBlock}; +use crate::block_verification_types::{AsBlock, AvailableBlockData, LookupBlock, RangeSyncBlock}; use crate::custody_context::NodeCustodyType; -use crate::data_column_verification::CustodyDataColumn; +use crate::data_availability_checker::DataAvailabilityChecker; use crate::graffiti_calculator::GraffitiSettings; -use crate::kzg_utils::build_data_column_sidecars; +use crate::kzg_utils::{build_data_column_sidecars_fulu, build_data_column_sidecars_gloas}; use crate::observed_operations::ObservationOutcome; pub use crate::persisted_beacon_chain::PersistedBeaconChain; -use crate::{BeaconBlockResponseWrapper, get_block_root}; +use crate::{BeaconBlockResponseWrapper, CustodyContext, get_block_root}; use crate::{ BeaconChain, BeaconChainTypes, BlockError, ChainConfig, ServerSentEventHandler, StateSkipConfig, @@ -27,12 +27,9 @@ use bls::{ use eth2::types::{GraffitiPolicy, SignedBlockContentsTuple}; use execution_layer::test_utils::generate_genesis_header; use execution_layer::{ - ExecutionLayer, + ExecutionLayer, NewPayloadRequest, NewPayloadRequestGloas, auth::JwtKey, - test_utils::{ - DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, ExecutionBlockGenerator, MockBuilder, - MockExecutionLayer, - }, + test_utils::{DEFAULT_JWT_SECRET, ExecutionBlockGenerator, MockBuilder, MockExecutionLayer}, }; use fixed_bytes::FixedBytesExtended; use futures::channel::mpsc::Receiver; @@ -52,7 +49,12 @@ use rayon::prelude::*; use sensitive_url::SensitiveUrl; use slot_clock::{SlotClock, TestingSlotClock}; use ssz_types::{RuntimeVariableList, VariableList}; +use state_processing::ConsensusContext; use state_processing::per_block_processing::compute_timestamp_at_slot; +use state_processing::per_block_processing::{ + BlockSignatureStrategy, VerifyBlockRoot, deneb::kzg_commitment_to_versioned_hash, + per_block_processing, +}; use state_processing::state_advance::complete_state_advance; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; @@ -65,11 +67,12 @@ use store::database::interface::BeaconNodeBackend; use store::{HotColdDB, ItemStore, MemoryStore, config::StoreConfig}; use task_executor::TaskExecutor; use task_executor::{ShutdownReason, test_utils::TestRuntime}; +use tracing::debug; use tree_hash::TreeHash; use typenum::U4294967296; -use types::data_column_custody_group::CustodyIndex; -use types::indexed_attestation::IndexedAttestationBase; -use types::payload::BlockProductionVersion; +use types::attestation::IndexedAttestationBase; +use types::data::CustodyIndex; +use types::execution::BlockProductionVersion; use types::test_utils::TestRandom; pub use types::test_utils::generate_deterministic_keypairs; use types::*; @@ -83,6 +86,8 @@ pub const FORK_NAME_ENV_VAR: &str = "FORK_NAME"; // `beacon_node/execution_layer/src/test_utils/fixtures/mainnet/test_blobs_bundle.ssz` pub const TEST_DATA_COLUMN_SIDECARS_SSZ: &[u8] = include_bytes!("test_utils/fixtures/test_data_column_sidecars.ssz"); +pub const TEST_DATA_COLUMN_SIDECARS_GLOAS_SSZ: &[u8] = + include_bytes!("test_utils/fixtures/test_data_column_sidecars_gloas.ssz"); // Default target aggregators to set during testing, this ensures an aggregator at each slot. // @@ -202,16 +207,44 @@ pub fn fork_name_from_env() -> Option<ForkName> { /// Return a `ChainSpec` suitable for test usage. /// /// If the `fork_from_env` feature is enabled, read the fork to use from the FORK_NAME environment -/// variable. Otherwise use the default spec. +/// variable. Otherwise we default to Bellatrix as the minimum fork (we no longer support +/// starting test networks prior to Bellatrix). pub fn test_spec<E: EthSpec>() -> ChainSpec { let mut spec = fork_name_from_env() .map(|fork| fork.make_genesis_spec(E::default_spec())) - .unwrap_or_else(|| E::default_spec()); + .unwrap_or_else(|| ForkName::Bellatrix.make_genesis_spec(E::default_spec())); // Set target aggregators to a high value by default. spec.target_aggregators_per_committee = DEFAULT_TARGET_AGGREGATORS; spec } +pub fn test_da_checker<E: EthSpec>( + spec: Arc<ChainSpec>, + node_custody_type: NodeCustodyType, +) -> DataAvailabilityChecker<EphemeralHarnessType<E>> { + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + let kzg = get_kzg(&spec); + let ordered_custody_column_indices = generate_data_column_indices_rand_order::<E>(); + let custody_context = Arc::new(CustodyContext::new( + node_custody_type, + ordered_custody_column_indices, + &spec, + )); + let complete_blob_backfill = false; + DataAvailabilityChecker::new( + complete_blob_backfill, + slot_clock, + kzg, + custody_context, + spec, + true, + ) + .expect("should initialise data availability checker") +} pub struct Builder<T: BeaconChainTypes> { eth_spec_instance: T::EthSpec, @@ -251,16 +284,25 @@ impl<E: EthSpec> Builder<EphemeralHarnessType<E>> { }); let mutator = move |builder: BeaconChainBuilder<_>| { - let header = generate_genesis_header::<E>(builder.get_spec(), false); + let spec = builder.get_spec(); + let header = generate_genesis_header::<E>(spec); let genesis_state = genesis_state_builder - .set_opt_execution_payload_header(header) + .set_opt_execution_payload_header(header.clone()) .build_genesis_state( &validator_keypairs, HARNESS_GENESIS_TIME, Hash256::from_slice(DEFAULT_ETH1_BLOCK_HASH), - builder.get_spec(), + spec, ) .expect("should generate interop state"); + // For post-Bellatrix forks, verify the merge is complete at genesis + if header.is_some() { + assert!( + state_processing::per_block_processing::is_merge_transition_complete( + &genesis_state + ) + ); + } builder .genesis_state(genesis_state) .expect("should build state using recent genesis") @@ -318,7 +360,7 @@ impl<E: EthSpec> Builder<DiskHarnessType<E>> { }); let mutator = move |builder: BeaconChainBuilder<_>| { - let header = generate_genesis_header::<E>(builder.get_spec(), false); + let header = generate_genesis_header::<E>(builder.get_spec()); let genesis_state = genesis_state_builder .set_opt_execution_payload_header(header) .build_genesis_state( @@ -503,25 +545,30 @@ where .expect("cannot recalculate fork times without spec"); mock.server.execution_block_generator().shanghai_time = spec.capella_fork_epoch.map(|epoch| { - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + genesis_time + + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); mock.server.execution_block_generator().cancun_time = spec.deneb_fork_epoch.map(|epoch| { - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + genesis_time + + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); mock.server.execution_block_generator().prague_time = spec.electra_fork_epoch.map(|epoch| { - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + genesis_time + + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); mock.server.execution_block_generator().eip7805_time = spec.eip7805_fork_epoch.map(|epoch| { - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + genesis_time + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); mock.server.execution_block_generator().osaka_time = spec.fulu_fork_epoch.map(|epoch| { - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + genesis_time + + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); mock.server.execution_block_generator().amsterdam_time = spec.gloas_fork_epoch.map(|epoch| { - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + genesis_time + + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); self @@ -566,7 +613,6 @@ where let (shutdown_tx, shutdown_receiver) = futures::channel::mpsc::channel(1); let spec = self.spec.expect("cannot build without spec"); - let seconds_per_slot = spec.seconds_per_slot; let validator_keypairs = self .validator_keypairs .expect("cannot build without validator keypairs"); @@ -611,7 +657,7 @@ where builder.slot_clock(testing_slot_clock) } else if builder.get_slot_clock().is_none() { builder - .testing_slot_clock(Duration::from_secs(seconds_per_slot)) + .testing_slot_clock(spec.get_slot_duration()) .expect("should configure testing slot clock") } else { builder @@ -638,29 +684,33 @@ pub fn mock_execution_layer_from_parts<E: EthSpec>( task_executor: TaskExecutor, ) -> MockExecutionLayer<E> { let shanghai_time = spec.capella_fork_epoch.map(|epoch| { - HARNESS_GENESIS_TIME + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + HARNESS_GENESIS_TIME + + (spec.get_slot_duration().as_secs()) * E::slots_per_epoch() * epoch.as_u64() }); let cancun_time = spec.deneb_fork_epoch.map(|epoch| { - HARNESS_GENESIS_TIME + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + HARNESS_GENESIS_TIME + + (spec.get_slot_duration().as_secs()) * E::slots_per_epoch() * epoch.as_u64() }); let prague_time = spec.electra_fork_epoch.map(|epoch| { - HARNESS_GENESIS_TIME + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + HARNESS_GENESIS_TIME + + (spec.get_slot_duration().as_secs()) * E::slots_per_epoch() * epoch.as_u64() }); let eip7805_time = spec.eip7805_fork_epoch.map(|epoch| { - HARNESS_GENESIS_TIME + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + HARNESS_GENESIS_TIME + spec.get_slot_duration().as_secs() * E::slots_per_epoch() * epoch.as_u64() }); let osaka_time = spec.fulu_fork_epoch.map(|epoch| { - HARNESS_GENESIS_TIME + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + HARNESS_GENESIS_TIME + + (spec.get_slot_duration().as_secs()) * E::slots_per_epoch() * epoch.as_u64() }); let amsterdam_time = spec.gloas_fork_epoch.map(|epoch| { - HARNESS_GENESIS_TIME + spec.seconds_per_slot * E::slots_per_epoch() * epoch.as_u64() + HARNESS_GENESIS_TIME + + (spec.get_slot_duration().as_secs()) * E::slots_per_epoch() * epoch.as_u64() }); let kzg = get_kzg(&spec); MockExecutionLayer::new( task_executor, - DEFAULT_TERMINAL_BLOCK, shanghai_time, cancun_time, prague_time, @@ -731,6 +781,36 @@ where .execution_block_generator() } + /// Create a switch-to-compounding `ConsolidationRequest` for the given validator. + /// + /// Panics if the validator doesn't exist, doesn't have eth1 withdrawal credentials, + /// or doesn't have an execution withdrawal address. + pub fn make_switch_to_compounding_request( + &self, + validator_index: usize, + ) -> ConsolidationRequest { + let head = self.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + let validator = head_state + .get_validator(validator_index) + .expect("validator should exist"); + + assert!( + validator.has_eth1_withdrawal_credential(&self.spec), + "validator {validator_index} should have eth1 withdrawal credentials" + ); + + let source_address = validator + .get_execution_withdrawal_address(&self.spec) + .expect("validator should have execution withdrawal address"); + + ConsolidationRequest { + source_address, + source_pubkey: validator.pubkey, + target_pubkey: validator.pubkey, + } + } + pub fn set_mock_builder( &mut self, beacon_url: SensitiveUrl, @@ -784,16 +864,20 @@ where mock_builder_server } - pub fn get_head_block(&self) -> RpcBlock<E> { + pub fn get_head_block(&self) -> RangeSyncBlock<E> { let block = self.chain.head_beacon_block(); let block_root = block.canonical_root(); - self.build_rpc_block_from_store_blobs(Some(block_root), block) + self.build_range_sync_block_from_store_blobs(Some(block_root), block) } - pub fn get_full_block(&self, block_root: &Hash256) -> RpcBlock<E> { - let block = self.chain.get_blinded_block(block_root).unwrap().unwrap(); + pub fn get_full_block(&self, block_root: &Hash256) -> RangeSyncBlock<E> { + let block = self + .chain + .get_blinded_block(block_root) + .unwrap() + .unwrap_or_else(|| panic!("block root does not exist in harness {block_root:?}")); let full_block = self.chain.store.make_full_block(block_root, block).unwrap(); - self.build_rpc_block_from_store_blobs(Some(*block_root), Arc::new(full_block)) + self.build_range_sync_block_from_store_blobs(Some(*block_root), Arc::new(full_block)) } pub fn get_all_validators(&self) -> Vec<usize> { @@ -1000,6 +1084,13 @@ where assert_ne!(slot, 0, "can't produce a block at slot 0"); assert!(slot >= state.slot()); + // For Gloas forks, delegate to make_block_with_envelope and discard the envelope. + if self.spec.fork_name_at_slot::<E>(slot).gloas_enabled() { + let (block_contents, _envelope, state) = + Box::pin(self.make_block_with_envelope(state, slot)).await; + return (block_contents, state); + } + complete_state_advance(&mut state, None, slot, &self.spec) .expect("should be able to advance state to slot"); @@ -1052,6 +1143,100 @@ where (block_contents, block_response.state) } + /// Returns a newly created block, signed by the proposer for the given slot, + /// along with the execution payload envelope (for Gloas) and the post-block state. + /// + /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. + pub async fn make_block_with_envelope( + &self, + mut state: BeaconState<E>, + slot: Slot, + ) -> ( + SignedBlockContentsTuple<E>, + Option<SignedExecutionPayloadEnvelope<E>>, + BeaconState<E>, + ) { + assert_ne!(slot, 0, "can't produce a block at slot 0"); + assert!(slot >= state.slot()); + + if state.fork_name_unchecked().gloas_enabled() + || self.spec.fork_name_at_slot::<E>(slot).gloas_enabled() + { + complete_state_advance(&mut state, None, slot, &self.spec) + .expect("should be able to advance state to slot"); + state.build_caches(&self.spec).expect("should build caches"); + + let proposer_index = state.get_beacon_proposer_index(slot, &self.spec).unwrap(); + + let graffiti = Graffiti::from(self.rng.lock().random::<[u8; 32]>()); + let graffiti_settings = + GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); + let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); + + // Load the parent's payload envelope and status from the cached head. + // TODO(gloas): we may want to pass these as arguments to support cases where we build + // on alternate chains to the head. + let (parent_payload_status, parent_envelope) = { + let head = self.chain.canonical_head.cached_head(); + ( + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), + ) + }; + + let (block, post_block_state, _consensus_block_value) = self + .chain + .produce_block_on_state_gloas( + state, + None, + parent_payload_status, + parent_envelope, + slot, + randao_reveal, + graffiti_settings, + ProduceBlockVerification::VerifyRandao, + None, + ) + .await + .unwrap(); + + let signed_block = Arc::new(block.sign( + &self.validator_keypairs[proposer_index].sk, + &post_block_state.fork(), + post_block_state.genesis_validators_root(), + &self.spec, + )); + + // Retrieve the cached envelope produced during block production and sign it. + let signed_envelope = self + .chain + .pending_payload_envelopes + .write() + .remove(slot) + .map(|envelope| { + let epoch = slot.epoch(E::slots_per_epoch()); + let domain = self.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &post_block_state.fork(), + post_block_state.genesis_validators_root(), + ); + let message = envelope.signing_root(domain); + let signature = self.validator_keypairs[proposer_index].sk.sign(message); + SignedExecutionPayloadEnvelope { + message: envelope, + signature, + } + }); + + let block_contents: SignedBlockContentsTuple<E> = (signed_block, None); + (block_contents, signed_envelope, post_block_state) + } else { + let (block_contents, state) = self.make_block(state, slot).await; + (block_contents, None, state) + } + } + /// Useful for the `per_block_processing` tests. Creates a block, and returns the state after /// caches are built but before the generated block is processed. pub async fn make_block_return_pre_state( @@ -1149,6 +1334,91 @@ where ) } + /// Build a Bellatrix block with the given execution payload, compute the + /// correct state root, sign it, and import it into the chain. + /// + /// This bypasses the normal block production pipeline, which always requests + /// a payload from the execution layer. That makes it possible to construct + /// blocks with **default (zeroed) payloads** — something the EL-backed flow + /// cannot do — which is needed to simulate the pre-merge portion of a chain + /// that starts at Bellatrix genesis with `is_merge_transition_complete = false`. + /// + /// `state` is expected to be the head state *before* `slot`. It will be + /// advanced to `slot` in-place via `complete_state_advance`, then used to + /// derive the proposer, RANDAO reveal, and parent root. After processing, + /// the caller should typically replace `state` with the chain's new head + /// state (`self.get_current_state()`). + pub async fn build_and_import_block_with_payload( + &self, + state: &mut BeaconState<E>, + slot: Slot, + execution_payload: ExecutionPayloadBellatrix<E>, + ) { + complete_state_advance(state, None, slot, &self.spec).expect("should advance state"); + state.build_caches(&self.spec).expect("should build caches"); + + let proposer_index = state.get_beacon_proposer_index(slot, &self.spec).unwrap(); + let randao_reveal = self.sign_randao_reveal(state, proposer_index, slot); + let parent_root = state.latest_block_header().canonical_root(); + + let mut block = BeaconBlock::Bellatrix(BeaconBlockBellatrix { + slot, + proposer_index: proposer_index as u64, + parent_root, + state_root: Hash256::zero(), + body: BeaconBlockBodyBellatrix { + randao_reveal, + eth1_data: state.eth1_data().clone(), + graffiti: Graffiti::default(), + proposer_slashings: VariableList::empty(), + attester_slashings: VariableList::empty(), + attestations: VariableList::empty(), + deposits: VariableList::empty(), + voluntary_exits: VariableList::empty(), + sync_aggregate: SyncAggregate::new(), + execution_payload: FullPayloadBellatrix { execution_payload }, + }, + }); + + // Run per_block_processing on a clone to compute the post-state root. + let signed_tmp = block.clone().sign( + &self.validator_keypairs[proposer_index].sk, + &state.fork(), + state.genesis_validators_root(), + &self.spec, + ); + let mut ctxt = ConsensusContext::new(slot).set_proposer_index(proposer_index as u64); + let mut post_state = state.clone(); + per_block_processing( + &mut post_state, + &signed_tmp, + BlockSignatureStrategy::NoVerification, + VerifyBlockRoot::False, + &mut ctxt, + &self.spec, + ) + .unwrap_or_else(|e| panic!("per_block_processing failed at slot {}: {e:?}", slot)); + + let state_root = post_state.update_tree_hash_cache().unwrap(); + *block.state_root_mut() = state_root; + + let signed_block = self.sign_beacon_block(block, state); + let block_root = signed_block.canonical_root(); + let lookup_block = LookupBlock::new(Arc::new(signed_block)); + self.chain.slot_clock.set_slot(slot.as_u64()); + self.chain + .process_block( + block_root, + lookup_block, + NotifyExecutionLayer::No, + BlockImportSource::Lookup, + || Ok(()), + ) + .await + .unwrap_or_else(|e| panic!("import failed at slot {}: {e:?}", slot)); + self.chain.recompute_head_at_current_slot().await; + } + #[allow(clippy::too_many_arguments)] pub fn produce_single_attestation_for_block( &self, @@ -1194,6 +1464,7 @@ where epoch, root: target_root, }, + false, &self.spec, )?; @@ -1346,6 +1617,7 @@ where epoch, root: target_root, }, + false, &self.spec, )?) } @@ -2456,20 +2728,41 @@ where ) -> Result<SignedBeaconBlockHash, BlockError> { self.set_current_slot(slot); let (block, blob_items) = block_contents; + // Determine if block is available: it's available if it doesn't require blobs, + // or if it requires blobs and we have them + let has_blob_commitments = block + .message() + .body() + .blob_kzg_commitments() + .is_ok_and(|c| !c.is_empty()); + let is_available = !has_blob_commitments || blob_items.is_some(); + let block_hash: SignedBeaconBlockHash = if !is_available { + self.chain + .process_block( + block_root, + LookupBlock::new(block), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + } else { + let range_sync_block = self.build_range_sync_block_from_blobs(block, blob_items)?; + self.chain + .process_block( + block_root, + range_sync_block, + NotifyExecutionLayer::Yes, + BlockImportSource::RangeSync, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + }; - let rpc_block = self.build_rpc_block_from_blobs(block_root, block, blob_items)?; - let block_hash: SignedBeaconBlockHash = self - .chain - .process_block( - block_root, - rpc_block, - NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, - || Ok(()), - ) - .await? - .try_into() - .expect("block blobs are available"); self.chain.recompute_head_at_current_slot().await; Ok(block_hash) } @@ -2481,30 +2774,128 @@ where let (block, blob_items) = block_contents; let block_root = block.canonical_root(); - let rpc_block = self.build_rpc_block_from_blobs(block_root, block, blob_items)?; - let block_hash: SignedBeaconBlockHash = self - .chain - .process_block( - block_root, - rpc_block, - NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, - || Ok(()), - ) - .await? - .try_into() - .expect("block blobs are available"); + // Determine if block is available: it's available if it doesn't require blobs, + // or if it requires blobs and we have them + let has_blob_commitments = block + .message() + .body() + .blob_kzg_commitments() + .is_ok_and(|c| !c.is_empty()); + let is_available = !has_blob_commitments || blob_items.is_some(); + let block_hash: SignedBeaconBlockHash = if is_available { + let range_sync_block = self.build_range_sync_block_from_blobs(block, blob_items)?; + self.chain + .process_block( + block_root, + range_sync_block, + NotifyExecutionLayer::Yes, + BlockImportSource::RangeSync, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + } else { + self.chain + .process_block( + block_root, + LookupBlock::new(block), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + }; + self.chain.recompute_head_at_current_slot().await; Ok(block_hash) } - /// Builds an `Rpc` block from a `SignedBeaconBlock` and blobs or data columns retrieved from + /// Verify and process (with fork choice) an execution payload envelope for a Gloas block. + pub async fn process_envelope( + &self, + block_root: Hash256, + signed_envelope: SignedExecutionPayloadEnvelope<E>, + state: &BeaconState<E>, + block_state_root: Hash256, + ) { + debug!( + slot = %signed_envelope.slot(), + "Processing execution payload envelope" + ); + + state_processing::envelope_processing::verify_execution_payload_envelope( + state, + &signed_envelope, + state_processing::VerifySignatures::True, + block_state_root, + &self.spec, + ) + .expect("should verify envelope"); + + // Notify the EL of the new payload so forkchoiceUpdated can reference it. + let block = self + .chain + .store + .get_blinded_block(&block_root) + .expect("should read block from store") + .expect("block should exist in store"); + + let bid = &block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message; + + let versioned_hashes = bid + .blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + let request = NewPayloadRequest::Gloas(NewPayloadRequestGloas { + execution_payload: &signed_envelope.message.payload, + versioned_hashes, + parent_beacon_block_root: block.message().parent_root(), + execution_requests: &signed_envelope.message.execution_requests, + il_transactions: Default::default(), + }); + + self.chain + .execution_layer + .as_ref() + .expect("harness should have execution layer") + .notify_new_payload(request) + .await + .expect("newPayload should succeed"); + + // Store the envelope. + self.chain + .store + .put_payload_envelope(&block_root, &signed_envelope) + .expect("should store envelope"); + + // Update fork choice so it knows the payload was received. + self.chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .expect("should update fork choice with envelope"); + + // Run fork choice because the envelope could become the head. + self.chain.recompute_head_at_current_slot().await; + } + + /// Builds a `RangeSyncBlock` from a `SignedBeaconBlock` and blobs or data columns retrieved from /// the database. - pub fn build_rpc_block_from_store_blobs( + pub fn build_range_sync_block_from_store_blobs( &self, block_root: Option<Hash256>, block: Arc<SignedBeaconBlock<E>>, - ) -> RpcBlock<E> { + ) -> RangeSyncBlock<E> { let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); let has_blobs = block .message() @@ -2512,46 +2903,82 @@ where .blob_kzg_commitments() .is_ok_and(|c| !c.is_empty()); if !has_blobs { - return RpcBlock::new_without_blobs(Some(block_root), block); + return RangeSyncBlock::new( + block, + AvailableBlockData::NoData, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + ) + .unwrap(); } // Blobs are stored as data columns from Fulu (PeerDAS) if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { - let columns = self.chain.get_data_columns(&block_root).unwrap().unwrap(); - let custody_columns = columns - .into_iter() - .map(CustodyDataColumn::from_asserted_custody) - .collect::<Vec<_>>(); - RpcBlock::new_with_custody_columns(Some(block_root), block, custody_columns).unwrap() + let fork_name = self.spec.fork_name_at_epoch(block.epoch()); + let columns = self + .chain + .get_data_columns(&block_root, fork_name) + .unwrap() + .unwrap(); + let custody_columns = columns.into_iter().collect::<Vec<_>>(); + let block_data = AvailableBlockData::new_with_data_columns(custody_columns); + RangeSyncBlock::new( + block, + block_data, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + ) + .unwrap() } else { let blobs = self.chain.get_blobs(&block_root).unwrap().blobs(); - RpcBlock::new(Some(block_root), block, blobs).unwrap() + let block_data = if let Some(blobs) = blobs { + AvailableBlockData::new_with_blobs(blobs) + } else { + AvailableBlockData::NoData + }; + + RangeSyncBlock::new( + block, + block_data, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + ) + .unwrap() } } - /// Builds an `RpcBlock` from a `SignedBeaconBlock` and `BlobsList`. - pub fn build_rpc_block_from_blobs( + /// Builds a `RangeSyncBlock` from a `SignedBeaconBlock` and `BlobsList`. + pub fn build_range_sync_block_from_blobs( &self, - block_root: Hash256, block: Arc<SignedBeaconBlock<E, FullPayload<E>>>, blob_items: Option<(KzgProofs<E>, BlobsList<E>)>, - ) -> Result<RpcBlock<E>, BlockError> { + ) -> Result<RangeSyncBlock<E>, BlockError> { Ok(if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { let epoch = block.slot().epoch(E::slots_per_epoch()); let sampling_columns = self.chain.sampling_columns_for_epoch(epoch); - if blob_items.is_some_and(|(_, blobs)| !blobs.is_empty()) { + if blob_items.is_some_and(|(kzg_proofs, _)| !kzg_proofs.is_empty()) { // Note: this method ignores the actual custody columns and just take the first // `sampling_column_count` for testing purpose only, because the chain does not // currently have any knowledge of the columns being custodied. let columns = generate_data_column_sidecars_from_block(&block, &self.spec) .into_iter() - .filter(|d| sampling_columns.contains(&d.index)) - .map(CustodyDataColumn::from_asserted_custody) + .filter(|d| sampling_columns.contains(d.index())) .collect::<Vec<_>>(); - RpcBlock::new_with_custody_columns(Some(block_root), block, columns)? + let block_data = AvailableBlockData::new_with_data_columns(columns); + RangeSyncBlock::new( + block, + block_data, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + )? } else { - RpcBlock::new_without_blobs(Some(block_root), block) + RangeSyncBlock::new( + block, + AvailableBlockData::NoData, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + )? } } else { let blobs = blob_items @@ -2560,7 +2987,18 @@ where }) .transpose() .unwrap(); - RpcBlock::new(Some(block_root), block, blobs)? + let block_data = if let Some(blobs) = blobs { + AvailableBlockData::new_with_blobs(blobs) + } else { + AvailableBlockData::NoData + }; + + RangeSyncBlock::new( + block, + block_data, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + )? }) } @@ -2662,7 +3100,8 @@ where BlockError, > { self.set_current_slot(slot); - let (block_contents, new_state) = self.make_block(state, slot).await; + let (block_contents, opt_envelope, new_state) = + self.make_block_with_envelope(state, slot).await; let block_hash = self .process_block( @@ -2671,6 +3110,12 @@ where block_contents.clone(), ) .await?; + + if let Some(envelope) = opt_envelope { + let block_state_root = block_contents.0.state_root(); + self.process_envelope(block_hash.into(), envelope, &new_state, block_state_root) + .await; + } Ok((block_hash, block_contents, new_state)) } @@ -3287,10 +3732,10 @@ where let verified_columns = generate_data_column_sidecars_from_block(block, &self.spec) .into_iter() - .filter(|c| custody_columns.contains(&c.index)) + .filter(|c| custody_columns.contains(c.index())) .map(|sidecar| { let subnet_id = - DataColumnSubnetId::from_column_index(sidecar.index, &self.spec); + DataColumnSubnetId::from_column_index(*sidecar.index(), &self.spec); self.chain .verify_data_column_sidecar_for_gossip(sidecar, subnet_id) }) @@ -3391,11 +3836,8 @@ pub fn generate_rand_block_and_blobs<E: EthSpec>( blobs, } = bundle; - for (index, ((blob, kzg_commitment), kzg_proof)) in blobs - .into_iter() - .zip(commitments.into_iter()) - .zip(proofs.into_iter()) - .enumerate() + for (index, ((blob, kzg_commitment), kzg_proof)) in + blobs.into_iter().zip(commitments).zip(proofs).enumerate() { blob_sidecars.push(BlobSidecar { index: index as u64, @@ -3432,51 +3874,99 @@ pub fn generate_data_column_sidecars_from_block<E: EthSpec>( block: &SignedBeaconBlock<E>, spec: &ChainSpec, ) -> DataColumnSidecarList<E> { - let kzg_commitments = block.message().body().blob_kzg_commitments().unwrap(); - if kzg_commitments.is_empty() { - return vec![]; + // Load the precomputed column sidecar to avoid computing them for every block in the tests. + // Then repeat the cells and proofs for every blob + if block.fork_name_unchecked().gloas_enabled() { + let kzg_commitments = &block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message + .blob_kzg_commitments; + if kzg_commitments.is_empty() { + return vec![]; + } + let num_blobs = kzg_commitments.len(); + let signed_block_header = block.signed_block_header(); + let template_data_columns = + RuntimeVariableList::<DataColumnSidecarGloas<E>>::from_ssz_bytes( + TEST_DATA_COLUMN_SIDECARS_GLOAS_SSZ, + E::number_of_columns(), + ) + .unwrap(); + + let (cells, proofs) = template_data_columns + .into_iter() + .map(|sidecar| { + let DataColumnSidecarGloas { + column, kzg_proofs, .. + } = sidecar; + // There's only one cell per column for a single blob + let cell_bytes: Vec<u8> = column.into_iter().next().unwrap().into(); + let kzg_cell = cell_bytes.try_into().unwrap(); + let kzg_proof = kzg_proofs.into_iter().next().unwrap(); + (kzg_cell, kzg_proof) + }) + .collect::<(Vec<_>, Vec<_>)>(); + + let blob_cells_and_proofs_vec = + vec![(cells.try_into().unwrap(), proofs.try_into().unwrap()); num_blobs]; + + build_data_column_sidecars_gloas( + signed_block_header.message.tree_hash_root(), + signed_block_header.message.slot, + blob_cells_and_proofs_vec, + spec, + ) + .unwrap() + } else { + let kzg_commitments = block.message().body().blob_kzg_commitments().unwrap(); + if kzg_commitments.is_empty() { + return vec![]; + } + + let kzg_commitments_inclusion_proof = block + .message() + .body() + .kzg_commitments_merkle_proof() + .unwrap(); + let signed_block_header = block.signed_block_header(); + + // load the precomputed column sidecar to avoid computing them for every block in the tests. + let template_data_columns = + RuntimeVariableList::<DataColumnSidecarFulu<E>>::from_ssz_bytes( + TEST_DATA_COLUMN_SIDECARS_SSZ, + E::number_of_columns(), + ) + .unwrap(); + + let (cells, proofs) = template_data_columns + .into_iter() + .map(|sidecar| { + let DataColumnSidecarFulu { + column, kzg_proofs, .. + } = sidecar; + // There's only one cell per column for a single blob + let cell_bytes: Vec<u8> = column.into_iter().next().unwrap().into(); + let kzg_cell = cell_bytes.try_into().unwrap(); + let kzg_proof = kzg_proofs.into_iter().next().unwrap(); + (kzg_cell, kzg_proof) + }) + .collect::<(Vec<_>, Vec<_>)>(); + + let blob_cells_and_proofs_vec = + vec![(cells.try_into().unwrap(), proofs.try_into().unwrap()); kzg_commitments.len()]; + + build_data_column_sidecars_fulu( + kzg_commitments.clone(), + kzg_commitments_inclusion_proof, + signed_block_header, + blob_cells_and_proofs_vec, + spec, + ) + .unwrap() } - - let kzg_commitments_inclusion_proof = block - .message() - .body() - .kzg_commitments_merkle_proof() - .unwrap(); - let signed_block_header = block.signed_block_header(); - - // load the precomputed column sidecar to avoid computing them for every block in the tests. - let template_data_columns = RuntimeVariableList::<DataColumnSidecar<E>>::from_ssz_bytes( - TEST_DATA_COLUMN_SIDECARS_SSZ, - E::number_of_columns(), - ) - .unwrap(); - - let (cells, proofs) = template_data_columns - .into_iter() - .map(|sidecar| { - let DataColumnSidecar { - column, kzg_proofs, .. - } = sidecar; - // There's only one cell per column for a single blob - let cell_bytes: Vec<u8> = column.into_iter().next().unwrap().into(); - let kzg_cell = cell_bytes.try_into().unwrap(); - let kzg_proof = kzg_proofs.into_iter().next().unwrap(); - (kzg_cell, kzg_proof) - }) - .collect::<(Vec<_>, Vec<_>)>(); - - // Repeat the cells and proofs for every blob - let blob_cells_and_proofs_vec = - vec![(cells.try_into().unwrap(), proofs.try_into().unwrap()); kzg_commitments.len()]; - - build_data_column_sidecars( - kzg_commitments.clone(), - kzg_commitments_inclusion_proof, - signed_block_header, - blob_cells_and_proofs_vec, - spec, - ) - .unwrap() } pub fn generate_data_column_indices_rand_order<E: EthSpec>() -> Vec<CustodyIndex> { diff --git a/beacon_node/beacon_chain/src/test_utils/fixtures/test_data_column_sidecars_gloas.ssz b/beacon_node/beacon_chain/src/test_utils/fixtures/test_data_column_sidecars_gloas.ssz new file mode 100644 index 0000000000..554b27844b Binary files /dev/null and b/beacon_node/beacon_chain/src/test_utils/fixtures/test_data_column_sidecars_gloas.ssz differ diff --git a/beacon_node/beacon_chain/src/validator_monitor.rs b/beacon_node/beacon_chain/src/validator_monitor.rs index fd8e690837..9d9aaf48dc 100644 --- a/beacon_node/beacon_chain/src/validator_monitor.rs +++ b/beacon_node/beacon_chain/src/validator_monitor.rs @@ -20,7 +20,7 @@ use std::io; use std::marker::PhantomData; use std::str::Utf8Error; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use store::AbstractExecPayload; use tracing::{debug, error, info, warn}; use types::consts::altair::{ @@ -1263,6 +1263,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { signed_aggregate_and_proof: &SignedAggregateAndProof<E>, indexed_attestation: &IndexedAttestation<E>, slot_clock: &S, + spec: &ChainSpec, ) { self.register_aggregated_attestation( "gossip", @@ -1270,6 +1271,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { signed_aggregate_and_proof, indexed_attestation, slot_clock, + spec, ) } @@ -1280,6 +1282,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { signed_aggregate_and_proof: &SignedAggregateAndProof<E>, indexed_attestation: &IndexedAttestation<E>, slot_clock: &S, + spec: &ChainSpec, ) { self.register_aggregated_attestation( "api", @@ -1287,6 +1290,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { signed_aggregate_and_proof, indexed_attestation, slot_clock, + spec, ) } @@ -1297,13 +1301,14 @@ impl<E: EthSpec> ValidatorMonitor<E> { signed_aggregate_and_proof: &SignedAggregateAndProof<E>, indexed_attestation: &IndexedAttestation<E>, slot_clock: &S, + spec: &ChainSpec, ) { let data = indexed_attestation.data(); let epoch = data.slot.epoch(E::slots_per_epoch()); let delay = get_message_delay_ms( seen_timestamp, data.slot, - slot_clock.agg_attestation_production_delay(), + spec.get_aggregate_attestation_due(), slot_clock, ); @@ -1488,12 +1493,14 @@ impl<E: EthSpec> ValidatorMonitor<E> { seen_timestamp: Duration, sync_committee_message: &SyncCommitteeMessage, slot_clock: &S, + spec: &ChainSpec, ) { self.register_sync_committee_message( "gossip", seen_timestamp, sync_committee_message, slot_clock, + spec, ) } @@ -1503,12 +1510,14 @@ impl<E: EthSpec> ValidatorMonitor<E> { seen_timestamp: Duration, sync_committee_message: &SyncCommitteeMessage, slot_clock: &S, + spec: &ChainSpec, ) { self.register_sync_committee_message( "api", seen_timestamp, sync_committee_message, slot_clock, + spec, ) } @@ -1519,15 +1528,15 @@ impl<E: EthSpec> ValidatorMonitor<E> { seen_timestamp: Duration, sync_committee_message: &SyncCommitteeMessage, slot_clock: &S, + spec: &ChainSpec, ) { if let Some(validator) = self.get_validator(sync_committee_message.validator_index) { let id = &validator.id; - let epoch = sync_committee_message.slot.epoch(E::slots_per_epoch()); let delay = get_message_delay_ms( seen_timestamp, sync_committee_message.slot, - slot_clock.sync_committee_message_production_delay(), + spec.get_sync_message_due(), slot_clock, ); @@ -1568,6 +1577,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { sync_contribution: &SignedContributionAndProof<E>, participant_pubkeys: &[PublicKeyBytes], slot_clock: &S, + spec: &ChainSpec, ) { self.register_sync_committee_contribution( "gossip", @@ -1575,6 +1585,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { sync_contribution, participant_pubkeys, slot_clock, + spec, ) } @@ -1585,6 +1596,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { sync_contribution: &SignedContributionAndProof<E>, participant_pubkeys: &[PublicKeyBytes], slot_clock: &S, + spec: &ChainSpec, ) { self.register_sync_committee_contribution( "api", @@ -1592,6 +1604,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { sync_contribution, participant_pubkeys, slot_clock, + spec, ) } @@ -1603,6 +1616,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { sync_contribution: &SignedContributionAndProof<E>, participant_pubkeys: &[PublicKeyBytes], slot_clock: &S, + spec: &ChainSpec, ) { let slot = sync_contribution.message.contribution.slot; let epoch = slot.epoch(E::slots_per_epoch()); @@ -1610,7 +1624,7 @@ impl<E: EthSpec> ValidatorMonitor<E> { let delay = get_message_delay_ms( seen_timestamp, slot, - slot_clock.sync_committee_contribution_production_delay(), + spec.get_contribution_message_due(), slot_clock, ); @@ -2087,13 +2101,6 @@ fn register_simulated_attestation( ); } -/// Returns the duration since the unix epoch. -pub fn timestamp_now() -> Duration { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) -} - fn u64_to_i64(n: impl Into<u64>) -> i64 { i64::try_from(n.into()).unwrap_or(i64::MAX) } diff --git a/beacon_node/beacon_chain/src/validator_pubkey_cache.rs b/beacon_node/beacon_chain/src/validator_pubkey_cache.rs index 26ac02d91b..36bf5c7113 100644 --- a/beacon_node/beacon_chain/src/validator_pubkey_cache.rs +++ b/beacon_node/beacon_chain/src/validator_pubkey_cache.rs @@ -302,7 +302,8 @@ mod test { #[test] fn basic_operation() { - let (state, keypairs) = get_state(8); + // >= 32 validators required for Gloas genesis with MainnetEthSpec (32 slots/epoch). + let (state, keypairs) = get_state(32); let store = get_store(); @@ -311,21 +312,14 @@ mod test { check_cache_get(&cache, &keypairs[..]); // Try adding a state with the same number of keypairs. - let (state, keypairs) = get_state(8); - cache - .import_new_pubkeys(&state) - .expect("should import pubkeys"); - check_cache_get(&cache, &keypairs[..]); - - // Try adding a state with less keypairs. - let (state, _) = get_state(1); + let (state, keypairs) = get_state(32); cache .import_new_pubkeys(&state) .expect("should import pubkeys"); check_cache_get(&cache, &keypairs[..]); // Try adding a state with more keypairs. - let (state, keypairs) = get_state(12); + let (state, keypairs) = get_state(48); cache .import_new_pubkeys(&state) .expect("should import pubkeys"); @@ -334,7 +328,7 @@ mod test { #[test] fn persistence() { - let (state, keypairs) = get_state(8); + let (state, keypairs) = get_state(32); let store = get_store(); @@ -349,7 +343,7 @@ mod test { check_cache_get(&cache, &keypairs[..]); // Add some more keypairs. - let (state, keypairs) = get_state(12); + let (state, keypairs) = get_state(48); let ops = cache .import_new_pubkeys(&state) .expect("should import pubkeys"); diff --git a/beacon_node/beacon_chain/tests/attestation_production.rs b/beacon_node/beacon_chain/tests/attestation_production.rs index 017c249d10..1b87fc041a 100644 --- a/beacon_node/beacon_chain/tests/attestation_production.rs +++ b/beacon_node/beacon_chain/tests/attestation_production.rs @@ -1,7 +1,10 @@ #![cfg(not(debug_assertions))] use beacon_chain::attestation_simulator::produce_unaggregated_attestation; -use beacon_chain::test_utils::{AttestationStrategy, BeaconChainHarness, BlockStrategy}; +use beacon_chain::custody_context::NodeCustodyType; +use beacon_chain::test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, fork_name_from_env, +}; use beacon_chain::validator_monitor::UNAGGREGATED_ATTESTATION_LAG_SLOTS; use beacon_chain::{StateSkipConfig, WhenSlotSkipped, metrics}; use bls::{AggregateSignature, Keypair}; @@ -9,7 +12,7 @@ use std::sync::{Arc, LazyLock}; use tree_hash::TreeHash; use types::{Attestation, EthSpec, MainnetEthSpec, RelativeEpoch, Slot}; -pub const VALIDATOR_COUNT: usize = 16; +pub const VALIDATOR_COUNT: usize = 32; /// A cached set of keys. static KEYPAIRS: LazyLock<Vec<Keypair>> = @@ -114,6 +117,8 @@ async fn produces_attestations() { .keypairs(KEYPAIRS[..].to_vec()) .fresh_ephemeral_store() .mock_execution_layer() + // SemiSupernode ensures enough columns are stored for sampling + custody validation for RpcBlock + .node_custody_type(NodeCustodyType::SemiSupernode) .build(); let chain = &harness.chain; @@ -203,7 +208,15 @@ async fn produces_attestations() { &AggregateSignature::infinity(), "bad signature" ); - assert_eq!(data.index, index, "bad index"); + if harness + .spec + .fork_name_at_slot::<MainnetEthSpec>(data.slot) + .gloas_enabled() + { + assert!(data.index <= 1, "invalid index"); + } else { + assert_eq!(data.index, index, "bad index"); + } assert_eq!(data.slot, slot, "bad slot"); assert_eq!(data.beacon_block_root, block_root, "bad block root"); assert_eq!( @@ -219,45 +232,39 @@ async fn produces_attestations() { assert_eq!(data.target.epoch, state.current_epoch(), "bad target epoch"); assert_eq!(data.target.root, target_root, "bad target root"); - let rpc_block = - harness.build_rpc_block_from_store_blobs(Some(block_root), Arc::new(block.clone())); - let beacon_chain::data_availability_checker::MaybeAvailableBlock::Available( - available_block, - ) = chain - .data_availability_checker - .verify_kzg_for_rpc_block(rpc_block) - .unwrap() - else { - panic!("block should be available") - }; + let range_sync_block = harness + .build_range_sync_block_from_store_blobs(Some(block_root), Arc::new(block.clone())); + let available_block = range_sync_block.into_available_block(); - let early_attestation = { - let proto_block = chain - .canonical_head - .fork_choice_read_lock() - .get_block(&block_root) - .unwrap(); - chain - .early_attester_cache - .add_head_block( - block_root, - &available_block, - proto_block, - &state, - &chain.spec, - ) - .unwrap(); - chain - .early_attester_cache - .try_attest(slot, index, &chain.spec) - .unwrap() - .unwrap() - }; + // For Gloas non-same-slot attestations, the early attester cache returns None. + let is_same_slot_attestation = slot == block_slot; + let is_gloas = harness + .spec + .fork_name_at_slot::<MainnetEthSpec>(slot) + .gloas_enabled(); + if !is_gloas || is_same_slot_attestation { + let early_attestation = { + let proto_block = chain + .canonical_head + .fork_choice_read_lock() + .get_block(&block_root) + .unwrap(); + chain + .early_attester_cache + .add_head_block(block_root, &available_block, proto_block, &state) + .unwrap(); + chain + .early_attester_cache + .try_attest(slot, index, &chain.spec) + .unwrap() + .unwrap() + }; - assert_eq!( - attestation, early_attestation, - "early attester cache inconsistent" - ); + assert_eq!( + attestation, early_attestation, + "early attester cache inconsistent" + ); + } } } } @@ -292,17 +299,12 @@ async fn early_attester_cache_old_request() { .get_block(&head.beacon_block_root) .unwrap(); - let rpc_block = harness - .build_rpc_block_from_store_blobs(Some(head.beacon_block_root), head.beacon_block.clone()); - let beacon_chain::data_availability_checker::MaybeAvailableBlock::Available(available_block) = - harness - .chain - .data_availability_checker - .verify_kzg_for_rpc_block(rpc_block) - .unwrap() - else { - panic!("block should be available") - }; + let available_block = harness + .build_range_sync_block_from_store_blobs( + Some(head.beacon_block_root), + head.beacon_block.clone(), + ) + .into_available_block(); harness .chain @@ -312,7 +314,6 @@ async fn early_attester_cache_old_request() { &available_block, head_proto_block, &head.beacon_state, - &harness.chain.spec, ) .unwrap(); @@ -330,3 +331,120 @@ async fn early_attester_cache_old_request() { .unwrap(); assert_eq!(attested_block.slot(), attest_slot); } + +/// Verify that `produce_unaggregated_attestation` sets `data.index = 1` (payload_present) +/// when a gloas validator attests to a prior slot whose block+envelope have been received. +/// +/// Setup: build a chain at gloas genesis, produce a block with envelope at slot N, +/// then advance the clock to slot N+1 without producing a block (skipped slot). +/// Attesting at slot N+1 should target the block at slot N with payload_present = true. +#[tokio::test] +async fn gloas_attestation_index_payload_present() { + if fork_name_from_env().is_some_and(|f| !f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .default_spec() + .keypairs(KEYPAIRS[..].to_vec()) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let chain = &harness.chain; + + // Build a few blocks so the chain is established (slots 1..=3). + harness.advance_slot(); + harness + .extend_chain( + 3, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let head = chain.head_snapshot(); + assert_eq!(head.beacon_block.slot(), Slot::new(3)); + + // Advance clock to slot 4 without producing a block (skipped slot). + harness.advance_slot(); + let attest_slot = chain.slot().unwrap(); + assert_eq!(attest_slot, Slot::new(4)); + + // Attest at slot 4 — this should target the block at slot 3 whose payload was received. + let attestation = chain + .produce_unaggregated_attestation(attest_slot, 0) + .expect("should produce attestation"); + + assert_eq!(attestation.data().slot, attest_slot); + assert_eq!( + attestation.data().index, + 1, + "gloas attestation to prior slot with payload should have index=1 (payload_present)" + ); +} + +/// Verify that `produce_unaggregated_attestation` sets `data.index = 0` (payload NOT present) +/// when a gloas validator attests to a prior slot whose block was imported but whose +/// payload envelope was never received. +/// +/// Setup: build a chain at gloas genesis through slot 2, then at slot 3 import only the +/// beacon block (no envelope), advance to slot 4 (skipped), and attest. +#[tokio::test] +async fn gloas_attestation_index_payload_absent() { + if fork_name_from_env().is_some_and(|f| !f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .default_spec() + .keypairs(KEYPAIRS[..].to_vec()) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let chain = &harness.chain; + + // Build slots 1..=2 normally (with envelopes). + harness.advance_slot(); + harness + .extend_chain( + 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + assert_eq!(chain.head_snapshot().beacon_block.slot(), Slot::new(2)); + + // Slot 3: produce and import the beacon block but do NOT process the envelope. + harness.advance_slot(); + let state = harness.get_current_state(); + let (block_contents, _envelope, _new_state) = + harness.make_block_with_envelope(state, Slot::new(3)).await; + + let block_root = block_contents.0.canonical_root(); + harness + .process_block(Slot::new(3), block_root, block_contents) + .await + .expect("block should import without envelope"); + + assert_eq!(chain.head_snapshot().beacon_block.slot(), Slot::new(3)); + + // Advance clock to slot 4 without producing a block (skipped slot). + harness.advance_slot(); + let attest_slot = chain.slot().unwrap(); + assert_eq!(attest_slot, Slot::new(4)); + + // Attest at slot 4 — targets slot 3 whose payload was NOT received. + let attestation = chain + .produce_unaggregated_attestation(attest_slot, 0) + .expect("should produce attestation"); + + assert_eq!(attestation.data().slot, attest_slot); + assert_eq!( + attestation.data().index, + 0, + "gloas attestation to prior slot without payload should have index=0 (payload_absent)" + ); +} diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 7984ea4708..da7f380e36 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::{ Error, batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations, @@ -14,18 +15,20 @@ use beacon_chain::{ }, }; use bls::{AggregateSignature, Keypair, SecretKey}; +use execution_layer::test_utils::generate_genesis_header; use fixed_bytes::FixedBytesExtended; use genesis::{DEFAULT_ETH1_BLOCK_HASH, interop_genesis_state}; use int_to_bytes::int_to_bytes32; +use slasher::{Config as SlasherConfig, Slasher}; use state_processing::per_slot_processing; use std::sync::{Arc, LazyLock}; +use tempfile::tempdir; use tree_hash::TreeHash; use typenum::Unsigned; use types::{ Address, Attestation, AttestationRef, ChainSpec, Epoch, EthSpec, ForkName, Hash256, MainnetEthSpec, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, SubnetId, - signed_aggregate_and_proof::SignedAggregateAndProofRefMut, - test_utils::generate_deterministic_keypair, + attestation::SignedAggregateAndProofRefMut, test_utils::generate_deterministic_keypair, }; pub type E = MainnetEthSpec; @@ -55,7 +58,7 @@ fn get_harness(validator_count: usize) -> BeaconChainHarness<EphemeralHarnessTyp let harness = BeaconChainHarness::builder(MainnetEthSpec) .spec(spec) .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .keypairs(KEYPAIRS[0..validator_count].to_vec()) @@ -80,11 +83,13 @@ fn get_harness_capella_spec( let spec = Arc::new(spec); let validator_keypairs = KEYPAIRS[0..validator_count].to_vec(); + // Use the proper genesis execution payload header that matches the mock execution layer + let execution_payload_header = generate_genesis_header(&spec); let genesis_state = interop_genesis_state( &validator_keypairs, HARNESS_GENESIS_TIME, Hash256::from_slice(DEFAULT_ETH1_BLOCK_HASH), - None, + execution_payload_header, &spec, ) .unwrap(); @@ -92,7 +97,7 @@ fn get_harness_capella_spec( let harness = BeaconChainHarness::builder(MainnetEthSpec) .spec(spec.clone()) .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .keypairs(validator_keypairs) @@ -107,11 +112,6 @@ fn get_harness_capella_spec( .mock_execution_layer() .build(); - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - harness.advance_slot(); (harness, spec) @@ -369,6 +369,13 @@ impl GossipTester { self.harness.chain.epoch().unwrap() } + pub fn is_gloas(&self) -> bool { + self.harness + .spec + .fork_name_at_slot::<E>(self.valid_attestation.data.slot) + .gloas_enabled() + } + pub fn earliest_valid_attestation_slot(&self) -> Slot { let offset = if self .harness @@ -523,6 +530,44 @@ impl GossipTester { self } + + /// Like `inspect_aggregate_err`, but only runs the check if gloas is enabled. + /// If gloas is not enabled, this is a no-op that returns self. + pub fn inspect_aggregate_err_if_gloas<G, I>( + self, + desc: &str, + get_attn: G, + inspect_err: I, + ) -> Self + where + G: Fn(&Self, &mut SignedAggregateAndProof<E>), + I: Fn(&Self, AttnError), + { + if self.is_gloas() { + self.inspect_aggregate_err(desc, get_attn, inspect_err) + } else { + self + } + } + + /// Like `inspect_unaggregate_err`, but only runs the check if gloas is enabled. + /// If gloas is not enabled, this is a no-op that returns self. + pub fn inspect_unaggregate_err_if_gloas<G, I>( + self, + desc: &str, + get_attn: G, + inspect_err: I, + ) -> Self + where + G: Fn(&Self, &mut SingleAttestation, &mut SubnetId, &ChainSpec), + I: Fn(&Self, AttnError), + { + if self.is_gloas() { + self.inspect_unaggregate_err(desc, get_attn, inspect_err) + } else { + self + } + } } /// Tests verification of `SignedAggregateAndProof` from the gossip network. #[tokio::test] @@ -855,6 +900,27 @@ async fn aggregated_gossip_verification() { )) }, ) + /* + * [New in Gloas]: attestation.data.index must be < 2 + */ + .inspect_aggregate_err_if_gloas( + "gloas: aggregate with index >= 2", + |_, a| match a.to_mut() { + SignedAggregateAndProofRefMut::Base(_) => { + panic!("Expected Electra attestation variant"); + } + SignedAggregateAndProofRefMut::Electra(att) => { + att.message.aggregate.data.index = 2; + } + }, + |_, err| { + assert!( + matches!(err, AttnError::CommitteeIndexInvalid), + "expected CommitteeIndexInvalid, got {:?}", + err + ) + }, + ) // NOTE: from here on, the tests are stateful, and rely on the valid attestation having // been seen. .import_valid_aggregate() @@ -1072,6 +1138,22 @@ async fn unaggregated_gossip_verification() { )) }, ) + /* + * [New in Gloas]: attestation.data.index must be < 2 + */ + .inspect_unaggregate_err_if_gloas( + "gloas: attestation with index >= 2", + |_, a, _, _| { + a.data.index = 2; + }, + |_, err| { + assert!( + matches!(err, AttnError::CommitteeIndexInvalid), + "expected CommitteeIndexInvalid, got {:?}", + err + ) + }, + ) // NOTE: from here on, the tests are stateful, and rely on the valid attestation having // been seen. .import_valid_unaggregate() @@ -1307,13 +1389,18 @@ async fn attestation_to_finalized_block() { let earlier_block_root = earlier_block.canonical_root(); assert_ne!(earlier_block_root, finalized_checkpoint.root); + // For Gloas, `block.state_root()` returns the pending state root, but the cold DB + // may store the full state root. Use `get_cold_state_root` to get the actual stored key. + let cold_state_root = harness + .chain + .store + .get_cold_state_root(earlier_slot) + .expect("should not error getting cold state root") + .expect("cold state root should be present for finalized slot in archive store"); + let mut state = harness .chain - .get_state( - &earlier_block.state_root(), - Some(earlier_slot), - CACHE_STATE_IN_TESTS, - ) + .get_state(&cold_state_root, Some(earlier_slot), CACHE_STATE_IN_TESTS) .expect("should not error getting state") .expect("should find state"); @@ -1701,3 +1788,235 @@ async fn aggregated_attestation_verification_use_head_state_fork() { ); } } + +/// [New in Gloas]: Tests that unaggregated attestations with `data.index == 1` are rejected +/// when `head_block.slot == attestation.data.slot`. +/// +/// This test only runs when `FORK_NAME=gloas` is set with `fork_from_env` feature. +// TODO(EIP-7732): Enable this test once gloas block production works in test harness. +// `state.latest_execution_payload_header()` not available in Gloas. +#[ignore] +#[tokio::test] +async fn gloas_unaggregated_attestation_same_slot_index_must_be_zero() { + let harness = get_harness(VALIDATOR_COUNT); + + // Skip this test if not running with gloas fork + if !harness + .spec + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + // Extend the chain out a few epochs so we have some chain depth to play with. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 3 - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block in the current slot (this creates the same-slot scenario) + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::SomeValidators(vec![]), + ) + .await; + + let current_slot = harness.chain.slot().expect("should get slot"); + let head = harness.chain.head_snapshot(); + + // Verify head block is in the current slot + assert_eq!( + head.beacon_block.slot(), + current_slot, + "head block should be in current slot for same-slot test" + ); + + // Produce an attestation for the current slot + let (mut attestation, _attester_sk, subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + + // Verify we have a same-slot scenario + let attested_block_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&attestation.data.beacon_block_root) + .expect("block should exist") + .slot; + assert_eq!( + attested_block_slot, attestation.data.slot, + "attested block slot should equal attestation slot for same-slot test" + ); + + // index == 1 should be rejected when head_block.slot == attestation.data.slot + attestation.data.index = 1; + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&attestation, Some(subnet_id)); + assert!( + matches!(result, Err(AttnError::CommitteeIndexNonZero(_))), + "gloas: attestation with index == 1 when head_block.slot == attestation.data.slot should be rejected, got {:?}", + result.err() + ); +} + +/// [New in Gloas]: Tests that aggregated attestations with `data.index == 1` are rejected +/// when `head_block.slot == attestation.data.slot`. +/// +/// This test only runs when `FORK_NAME=gloas` is set with `fork_from_env` feature. +// TODO(EIP-7732): Enable this test once gloas block production works in test harness. +// `state.latest_execution_payload_header()` not available in Gloas. +#[ignore] +#[tokio::test] +async fn gloas_aggregated_attestation_same_slot_index_must_be_zero() { + let harness = get_harness(VALIDATOR_COUNT); + + // Skip this test if not running with gloas fork + if !harness + .spec + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + // Extend the chain out a few epochs so we have some chain depth to play with. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 3 - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block in the current slot (this creates the same-slot scenario) + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::SomeValidators(vec![]), + ) + .await; + + let current_slot = harness.chain.slot().expect("should get slot"); + let head = harness.chain.head_snapshot(); + + // Verify head block is in the current slot + assert_eq!( + head.beacon_block.slot(), + current_slot, + "head block should be in current slot for same-slot test" + ); + + // Produce an attestation for the current slot + let (valid_attestation, _attester_sk, _subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + + // Verify we have a same-slot scenario + let attested_block_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&valid_attestation.data.beacon_block_root) + .expect("block should exist") + .slot; + assert_eq!( + attested_block_slot, valid_attestation.data.slot, + "attested block slot should equal attestation slot for same-slot test" + ); + + // Convert to aggregate + let committee = head + .beacon_state + .get_beacon_committee(current_slot, valid_attestation.committee_index) + .expect("should get committee"); + let fork_name = harness + .spec + .fork_name_at_slot::<E>(valid_attestation.data.slot); + let aggregate_attestation = + single_attestation_to_attestation(&valid_attestation, committee.committee, fork_name) + .unwrap(); + + let (mut valid_aggregate, _, _) = + get_valid_aggregated_attestation(&harness.chain, aggregate_attestation); + + // index == 1 should be rejected when head_block.slot == attestation.data.slot + match valid_aggregate.to_mut() { + SignedAggregateAndProofRefMut::Base(att) => { + att.message.aggregate.data.index = 1; + } + SignedAggregateAndProofRefMut::Electra(att) => { + att.message.aggregate.data.index = 1; + } + } + + let result = harness + .chain + .verify_aggregated_attestation_for_gossip(&valid_aggregate); + assert!( + matches!(result, Err(AttnError::CommitteeIndexNonZero(_))), + "gloas: aggregate with index == 1 when head_block.slot == attestation.data.slot should be rejected, got {:?}", + result.err() + ); +} + +/// Regression test: a SingleAttestation with a huge bogus attester_index must not be forwarded to +/// the slasher. Previously the slasher received the IndexedAttestation before committee-membership +/// validation, causing an OOM when the slasher tried to allocate based on the untrusted index. +#[tokio::test] +async fn unaggregated_attestation_bogus_attester_index_not_sent_to_slasher() { + let slasher_dir = tempdir().unwrap(); + let spec = Arc::new(test_spec::<E>()); + let slasher = Arc::new( + Slasher::<E>::open(SlasherConfig::new(slasher_dir.path().into()), spec.clone()).unwrap(), + ); + + let inner_slasher = slasher.clone(); + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec) + .keypairs(KEYPAIRS[0..VALIDATOR_COUNT].to_vec()) + .fresh_ephemeral_store() + .initial_mutator(Box::new(move |builder| builder.slasher(inner_slasher))) + .mock_execution_layer() + .build(); + harness.advance_slot(); + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + harness.advance_slot(); + + // Build a valid SingleAttestation, then replace the attester_index with a huge value. + let (mut bogus_attestation, _, _) = get_valid_unaggregated_attestation(&harness.chain); + bogus_attestation.attester_index = 1 << 40; // ~2^40, would OOM the slasher + + // Drain any attestations already queued from block production. + slasher + .process_queued(harness.get_current_slot().epoch(E::slots_per_epoch())) + .unwrap(); + let queue_len_before = slasher.attestation_queue_len(); + assert_eq!(queue_len_before, 0); + + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&bogus_attestation, None); + assert!( + result.is_err(), + "attestation with bogus index should fail verification" + ); + + assert_eq!( + slasher.attestation_queue_len(), + 0, + "slasher queue length must not change — bogus attestation must not be forwarded" + ); +} diff --git a/beacon_node/beacon_chain/tests/bellatrix.rs b/beacon_node/beacon_chain/tests/bellatrix.rs deleted file mode 100644 index 5d466dd1d3..0000000000 --- a/beacon_node/beacon_chain/tests/bellatrix.rs +++ /dev/null @@ -1,212 +0,0 @@ -#![cfg(not(debug_assertions))] // Tests run too slow in debug. - -use beacon_chain::test_utils::BeaconChainHarness; -use execution_layer::test_utils::{Block, DEFAULT_TERMINAL_BLOCK, generate_pow_block}; -use types::*; - -const VALIDATOR_COUNT: usize = 32; - -type E = MainnetEthSpec; - -fn verify_execution_payload_chain<E: EthSpec>(chain: &[FullPayload<E>]) { - let mut prev_ep: Option<FullPayload<E>> = None; - - for ep in chain { - assert!(!ep.is_default_with_empty_roots()); - assert!(ep.block_hash() != ExecutionBlockHash::zero()); - - // Check against previous `ExecutionPayload`. - if let Some(prev_ep) = prev_ep { - assert_eq!(prev_ep.block_hash(), ep.parent_hash()); - assert_eq!(prev_ep.block_number() + 1, ep.block_number()); - assert!(ep.timestamp() > prev_ep.timestamp()); - } - prev_ep = Some(ep.clone()); - } -} - -#[tokio::test] -// TODO(merge): This isn't working cause the non-zero values in `initialize_beacon_state_from_eth1` -// are causing failed lookups to the execution node. I need to come back to this. -#[should_panic] -async fn merge_with_terminal_block_hash_override() { - let altair_fork_epoch = Epoch::new(0); - let bellatrix_fork_epoch = Epoch::new(0); - - let mut spec = E::default_spec(); - spec.altair_fork_epoch = Some(altair_fork_epoch); - spec.bellatrix_fork_epoch = Some(bellatrix_fork_epoch); - - let genesis_pow_block_hash = generate_pow_block( - spec.terminal_total_difficulty, - DEFAULT_TERMINAL_BLOCK, - 0, - ExecutionBlockHash::zero(), - ) - .unwrap() - .block_hash; - - spec.terminal_block_hash = genesis_pow_block_hash; - - let harness = BeaconChainHarness::builder(E::default()) - .spec(spec.into()) - .deterministic_keypairs(VALIDATOR_COUNT) - .fresh_ephemeral_store() - .mock_execution_layer() - .build(); - - assert_eq!( - harness - .execution_block_generator() - .latest_block() - .unwrap() - .block_hash(), - genesis_pow_block_hash, - "pre-condition" - ); - - assert!( - harness - .chain - .head_snapshot() - .beacon_block - .as_bellatrix() - .is_ok(), - "genesis block should be a bellatrix block" - ); - - let mut execution_payloads = vec![]; - for i in 0..E::slots_per_epoch() * 3 { - harness.extend_slots(1).await; - - let block = &harness.chain.head_snapshot().beacon_block; - - let execution_payload = block.message().body().execution_payload().unwrap(); - if i == 0 { - assert_eq!(execution_payload.block_hash(), genesis_pow_block_hash); - } - execution_payloads.push(execution_payload.into()); - } - - verify_execution_payload_chain(execution_payloads.as_slice()); -} - -#[tokio::test] -async fn base_altair_bellatrix_with_terminal_block_after_fork() { - let altair_fork_epoch = Epoch::new(4); - let altair_fork_slot = altair_fork_epoch.start_slot(E::slots_per_epoch()); - let bellatrix_fork_epoch = Epoch::new(8); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(E::slots_per_epoch()); - - let mut spec = E::default_spec(); - spec.altair_fork_epoch = Some(altair_fork_epoch); - spec.bellatrix_fork_epoch = Some(bellatrix_fork_epoch); - - let mut execution_payloads = vec![]; - - let harness = BeaconChainHarness::builder(E::default()) - .spec(spec.into()) - .deterministic_keypairs(VALIDATOR_COUNT) - .fresh_ephemeral_store() - .mock_execution_layer() - .build(); - - /* - * Start with the base fork. - */ - - assert!(harness.chain.head_snapshot().beacon_block.as_base().is_ok()); - - /* - * Do the Altair fork. - */ - - harness.extend_to_slot(altair_fork_slot).await; - - let altair_head = &harness.chain.head_snapshot().beacon_block; - assert!(altair_head.as_altair().is_ok()); - assert_eq!(altair_head.slot(), altair_fork_slot); - - /* - * Do the Bellatrix fork, without a terminal PoW block. - */ - - Box::pin(harness.extend_to_slot(bellatrix_fork_slot)).await; - - let bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!(bellatrix_head.as_bellatrix().is_ok()); - assert_eq!(bellatrix_head.slot(), bellatrix_fork_slot); - assert!( - bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Bellatrix head is default payload" - ); - - /* - * Next Bellatrix block shouldn't include an exec payload. - */ - - harness.extend_slots(1).await; - - let one_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - one_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "One after bellatrix head is default payload" - ); - assert_eq!(one_after_bellatrix_head.slot(), bellatrix_fork_slot + 1); - - /* - * Trigger the terminal PoW block. - */ - - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - - // Add a slot duration to get to the next slot - let timestamp = harness.get_timestamp_at_slot() + harness.spec.seconds_per_slot; - - harness - .execution_block_generator() - .modify_last_block(|block| { - if let Block::PoW(terminal_block) = block { - terminal_block.timestamp = timestamp; - } - }); - - harness.extend_slots(1).await; - - let two_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - two_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Two after bellatrix head is default payload" - ); - assert_eq!(two_after_bellatrix_head.slot(), bellatrix_fork_slot + 2); - - /* - * Next Bellatrix block should include an exec payload. - */ - for _ in 0..4 { - harness.extend_slots(1).await; - - let block = &harness.chain.head_snapshot().beacon_block; - execution_payloads.push(block.message().body().execution_payload().unwrap().into()); - } - - verify_execution_payload_chain(execution_payloads.as_slice()); -} diff --git a/beacon_node/beacon_chain/tests/blob_verification.rs b/beacon_node/beacon_chain/tests/blob_verification.rs index d1a0d87adf..0ee9a7dba6 100644 --- a/beacon_node/beacon_chain/tests/blob_verification.rs +++ b/beacon_node/beacon_chain/tests/blob_verification.rs @@ -5,12 +5,12 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{ AvailabilityProcessingStatus, BlockError, ChainConfig, InvalidSignature, NotifyExecutionLayer, - block_verification_types::AsBlock, + block_verification_types::{AsBlock, LookupBlock}, }; use bls::{Keypair, Signature}; use logging::create_test_tracing_subscriber; use std::sync::{Arc, LazyLock}; -use types::{blob_sidecar::FixedBlobSidecarList, *}; +use types::{data::FixedBlobSidecarList, *}; type E = MainnetEthSpec; @@ -29,7 +29,7 @@ fn get_harness( let harness = BeaconChainHarness::builder(MainnetEthSpec) .spec(spec) .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .keypairs(KEYPAIRS[0..validator_count].to_vec()) @@ -76,20 +76,18 @@ async fn rpc_blobs_with_invalid_header_signature() { // Process the block without blobs so that it doesn't become available. harness.advance_slot(); - let rpc_block = harness - .build_rpc_block_from_blobs(block_root, signed_block.clone(), None) - .unwrap(); let availability = harness .chain .process_block( block_root, - rpc_block, + LookupBlock::new(signed_block.clone()), NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, + BlockImportSource::Lookup, || Ok(()), ) .await .unwrap(); + assert_eq!( availability, AvailabilityProcessingStatus::MissingComponents(slot, block_root) @@ -114,6 +112,8 @@ async fn rpc_blobs_with_invalid_header_signature() { .process_rpc_blobs(slot, block_root, blob_sidecars) .await .unwrap_err(); + + println!("{:?}", err); assert!(matches!( err, BlockError::InvalidSignature(InvalidSignature::ProposerSignature) diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index eae443d889..1b117c0ecb 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -1,9 +1,11 @@ #![cfg(not(debug_assertions))] - -use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, RpcBlock}; +// TODO(gloas) we probably need similar test for payload envelope verification +use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, LookupBlock, RangeSyncBlock}; +use beacon_chain::data_availability_checker::{AvailabilityCheckError, AvailableBlockData}; use beacon_chain::data_column_verification::CustodyDataColumn; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes, ExecutionPendingBlock, + WhenSlotSkipped, custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, test_spec, @@ -18,10 +20,9 @@ use fixed_bytes::FixedBytesExtended; use logging::create_test_tracing_subscriber; use slasher::{Config as SlasherConfig, Slasher}; use state_processing::{ - BlockProcessingError, ConsensusContext, VerifyBlockRoot, + BlockProcessingError, BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, common::{attesting_indices_base, attesting_indices_electra}, - per_block_processing::{BlockSignatureStrategy, per_block_processing}, - per_slot_processing, + per_block_processing, per_slot_processing, }; use std::marker::PhantomData; use std::sync::{Arc, LazyLock}; @@ -30,8 +31,8 @@ use types::{test_utils::generate_deterministic_keypair, *}; type E = MainnetEthSpec; -// Should ideally be divisible by 3. -const VALIDATOR_COUNT: usize = 24; +// Gloas requires >= 1 validator per slot for PTC committee computation, so >= 32 for MainnetEthSpec. +const VALIDATOR_COUNT: usize = 32; const CHAIN_SEGMENT_LENGTH: usize = 64 * 5; const BLOCK_INDICES: &[usize] = &[0, 1, 32, 64, 68 + 1, 129, CHAIN_SEGMENT_LENGTH - 1]; @@ -39,6 +40,7 @@ const BLOCK_INDICES: &[usize] = &[0, 1, 32, 64, 68 + 1, 129, CHAIN_SEGMENT_LENGT static KEYPAIRS: LazyLock<Vec<Keypair>> = LazyLock::new(|| types::test_utils::generate_deterministic_keypairs(VALIDATOR_COUNT)); +// TODO(#8633): Delete this unnecessary enum and refactor this file to use `AvailableBlockData` instead. enum DataSidecars<E: EthSpec> { Blobs(BlobSidecarList<E>), DataColumns(Vec<CustodyDataColumn<E>>), @@ -77,14 +79,17 @@ async fn get_chain_segment() -> (Vec<BeaconSnapshot<E>>, Vec<Option<DataSidecars segment.push(BeaconSnapshot { beacon_block_root: snapshot.beacon_block_root, + execution_envelope: snapshot.execution_envelope, beacon_block: Arc::new(full_block), beacon_state: snapshot.beacon_state, }); + let fork_name = snapshot.beacon_block.fork_name_unchecked(); + let data_sidecars = if harness.spec.is_peer_das_enabled_for_epoch(block_epoch) { harness .chain - .get_data_columns(&snapshot.beacon_block_root) + .get_data_columns(&snapshot.beacon_block_root, fork_name) .unwrap() .map(|columns| { columns @@ -114,7 +119,7 @@ fn get_harness( let harness = BeaconChainHarness::builder(MainnetEthSpec) .default_spec() .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .keypairs(KEYPAIRS[0..validator_count].to_vec()) @@ -128,32 +133,106 @@ fn get_harness( harness } -fn chain_segment_blocks( +fn chain_segment_blocks<T>( chain_segment: &[BeaconSnapshot<E>], chain_segment_sidecars: &[Option<DataSidecars<E>>], -) -> Vec<RpcBlock<E>> { + chain: Arc<BeaconChain<T>>, +) -> Vec<RangeSyncBlock<E>> +where + T: BeaconChainTypes<EthSpec = E>, +{ chain_segment .iter() .zip(chain_segment_sidecars.iter()) .map(|(snapshot, data_sidecars)| { let block = snapshot.beacon_block.clone(); - build_rpc_block(block, data_sidecars) + build_range_sync_block(block, data_sidecars, chain.clone()) }) .collect() } -fn build_rpc_block( +fn build_range_sync_block<T>( block: Arc<SignedBeaconBlock<E>>, data_sidecars: &Option<DataSidecars<E>>, -) -> RpcBlock<E> { + chain: Arc<BeaconChain<T>>, +) -> RangeSyncBlock<E> +where + T: BeaconChainTypes<EthSpec = E>, +{ match data_sidecars { Some(DataSidecars::Blobs(blobs)) => { - RpcBlock::new(None, block, Some(blobs.clone())).unwrap() + let block_data = AvailableBlockData::new_with_blobs(blobs.clone()); + RangeSyncBlock::new( + block, + block_data, + &chain.data_availability_checker, + chain.spec.clone(), + ) + .unwrap() } Some(DataSidecars::DataColumns(columns)) => { - RpcBlock::new_with_custody_columns(None, block, columns.clone()).unwrap() + let block_data = AvailableBlockData::new_with_data_columns( + columns + .iter() + .map(|c| c.as_data_column().clone()) + .collect::<Vec<_>>(), + ); + RangeSyncBlock::new( + block, + block_data, + &chain.data_availability_checker, + chain.spec.clone(), + ) + .unwrap() + } + None => RangeSyncBlock::new( + block, + AvailableBlockData::NoData, + &chain.data_availability_checker, + chain.spec.clone(), + ) + .unwrap(), + } +} + +/// Pre-store execution payload envelopes in the harness's store. +/// +/// Post-Gloas, block N+1 needs block N's envelope to be available when it is +/// imported. This function stores all envelopes from the chain segment so that +/// `process_chain_segment` can import all blocks successfully. +// TODO(gloas): this is a bit of a hack that can be removed once `process_chain_segment` handles +// payload envelopes +fn store_envelopes_for_chain_segment( + chain_segment: &[BeaconSnapshot<E>], + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, +) { + for snapshot in chain_segment { + if let Some(ref envelope) = snapshot.execution_envelope { + harness + .chain + .store + .put_payload_envelope(&snapshot.beacon_block_root, envelope) + .expect("should store envelope"); + } + } +} + +/// Update fork choice with envelope payload status for all blocks in the chain segment. +/// +/// Must be called after the blocks have been imported into fork choice. +fn update_fork_choice_with_envelopes( + chain_segment: &[BeaconSnapshot<E>], + harness: &BeaconChainHarness<EphemeralHarnessType<E>>, +) { + for snapshot in chain_segment { + if snapshot.execution_envelope.is_some() { + // Call may fail if block was invalid (it will have no fork choice node). + let _ = harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(snapshot.beacon_block_root); } - None => RpcBlock::new_without_blobs(None, block), } } @@ -244,18 +323,18 @@ fn update_data_column_signed_header<E: EthSpec>( ) { for old_custody_column_sidecar in data_columns.as_mut_slice() { let old_column_sidecar = old_custody_column_sidecar.as_data_column(); - let new_column_sidecar = Arc::new(DataColumnSidecar::<E> { - index: old_column_sidecar.index, - column: old_column_sidecar.column.clone(), - kzg_commitments: old_column_sidecar.kzg_commitments.clone(), - kzg_proofs: old_column_sidecar.kzg_proofs.clone(), + let new_column_sidecar = Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: *old_column_sidecar.index(), + column: old_column_sidecar.column().clone(), + kzg_commitments: old_column_sidecar.kzg_commitments().unwrap().clone(), + kzg_proofs: old_column_sidecar.kzg_proofs().clone(), signed_block_header: signed_block.signed_block_header(), kzg_commitments_inclusion_proof: signed_block .message() .body() .kzg_commitments_merkle_proof() .unwrap(), - }); + })); *old_custody_column_sidecar = CustodyDataColumn::from_asserted_custody(new_column_sidecar); } } @@ -264,9 +343,11 @@ fn update_data_column_signed_header<E: EthSpec>( async fn chain_segment_full_segment() { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); let (chain_segment, chain_segment_blobs) = get_chain_segment().await; - let blocks: Vec<RpcBlock<E>> = chain_segment_blocks(&chain_segment, &chain_segment_blobs) - .into_iter() - .collect(); + store_envelopes_for_chain_segment(&chain_segment, &harness); + let blocks: Vec<RangeSyncBlock<E>> = + chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) + .into_iter() + .collect(); harness .chain @@ -288,6 +369,7 @@ async fn chain_segment_full_segment() { .into_block_error() .expect("should import chain segment"); + update_fork_choice_with_envelopes(&chain_segment, &harness); harness.chain.recompute_head_at_current_slot().await; assert_eq!( @@ -300,12 +382,15 @@ async fn chain_segment_full_segment() { #[tokio::test] async fn chain_segment_varying_chunk_size() { let (chain_segment, chain_segment_blobs) = get_chain_segment().await; - let blocks: Vec<RpcBlock<E>> = chain_segment_blocks(&chain_segment, &chain_segment_blobs) - .into_iter() - .collect(); + let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); + let blocks: Vec<RangeSyncBlock<E>> = + chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) + .into_iter() + .collect(); for chunk_size in &[1, 2, 31, 32, 33] { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); + store_envelopes_for_chain_segment(&chain_segment, &harness); harness .chain @@ -321,6 +406,7 @@ async fn chain_segment_varying_chunk_size() { .unwrap_or_else(|_| panic!("should import chain segment of len {}", chunk_size)); } + update_fork_choice_with_envelopes(&chain_segment, &harness); harness.chain.recompute_head_at_current_slot().await; assert_eq!( @@ -344,9 +430,10 @@ async fn chain_segment_non_linear_parent_roots() { /* * Test with a block removed. */ - let mut blocks: Vec<RpcBlock<E>> = chain_segment_blocks(&chain_segment, &chain_segment_blobs) - .into_iter() - .collect(); + let mut blocks: Vec<RangeSyncBlock<E>> = + chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) + .into_iter() + .collect(); blocks.remove(2); assert!( @@ -364,16 +451,21 @@ async fn chain_segment_non_linear_parent_roots() { /* * Test with a modified parent root. */ - let mut blocks: Vec<RpcBlock<E>> = chain_segment_blocks(&chain_segment, &chain_segment_blobs) - .into_iter() - .collect(); + let mut blocks: Vec<RangeSyncBlock<E>> = + chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) + .into_iter() + .collect(); let (mut block, signature) = blocks[3].as_block().clone().deconstruct(); *block.parent_root_mut() = Hash256::zero(); - blocks[3] = RpcBlock::new_without_blobs( - None, + + blocks[3] = RangeSyncBlock::new( Arc::new(SignedBeaconBlock::from_block(block, signature)), - ); + blocks[3].block_data().clone(), + &harness.chain.data_availability_checker, + harness.spec.clone(), + ) + .unwrap(); assert!( matches!( @@ -401,15 +493,19 @@ async fn chain_segment_non_linear_slots() { * Test where a child is lower than the parent. */ - let mut blocks: Vec<RpcBlock<E>> = chain_segment_blocks(&chain_segment, &chain_segment_blobs) - .into_iter() - .collect(); + let mut blocks: Vec<RangeSyncBlock<E>> = + chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) + .into_iter() + .collect(); let (mut block, signature) = blocks[3].as_block().clone().deconstruct(); *block.slot_mut() = Slot::new(0); - blocks[3] = RpcBlock::new_without_blobs( - None, + blocks[3] = RangeSyncBlock::new( Arc::new(SignedBeaconBlock::from_block(block, signature)), - ); + blocks[3].block_data().clone(), + &harness.chain.data_availability_checker, + harness.spec.clone(), + ) + .unwrap(); assert!( matches!( @@ -427,15 +523,19 @@ async fn chain_segment_non_linear_slots() { * Test where a child is equal to the parent. */ - let mut blocks: Vec<RpcBlock<E>> = chain_segment_blocks(&chain_segment, &chain_segment_blobs) - .into_iter() - .collect(); + let mut blocks: Vec<RangeSyncBlock<E>> = + chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) + .into_iter() + .collect(); let (mut block, signature) = blocks[3].as_block().clone().deconstruct(); *block.slot_mut() = blocks[2].slot(); - blocks[3] = RpcBlock::new_without_blobs( - None, + blocks[3] = RangeSyncBlock::new( Arc::new(SignedBeaconBlock::from_block(block, signature)), - ); + blocks[3].block_data().clone(), + &harness.chain.data_availability_checker, + harness.chain.spec.clone(), + ) + .unwrap(); assert!( matches!( @@ -458,10 +558,13 @@ async fn assert_invalid_signature( snapshots: &[BeaconSnapshot<E>], item: &str, ) { - let blocks: Vec<RpcBlock<E>> = snapshots + store_envelopes_for_chain_segment(chain_segment, harness); + let blocks: Vec<RangeSyncBlock<E>> = snapshots .iter() .zip(chain_segment_blobs.iter()) - .map(|(snapshot, blobs)| build_rpc_block(snapshot.beacon_block.clone(), blobs)) + .map(|(snapshot, blobs)| { + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + }) .collect(); // Ensure the block will be rejected if imported in a chain segment. @@ -482,11 +585,25 @@ async fn assert_invalid_signature( harness.chain.recompute_head_at_current_slot().await; // Ensure the block will be rejected if imported on its own (without gossip checking). - let ancestor_blocks = chain_segment + // Only include blocks that haven't been imported yet (after the finalized slot) to avoid + // `WouldRevertFinalizedSlot` errors when part 1 already imported and finalized some blocks. + // Use the fork choice finalized checkpoint directly, as the cached head may not reflect + // finalization that occurred during process_chain_segment. + let finalized_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .finalized_checkpoint() + .epoch + .start_slot(E::slots_per_epoch()); + let ancestor_blocks: Vec<RangeSyncBlock<E>> = chain_segment .iter() .take(block_index) .zip(chain_segment_blobs.iter()) - .map(|(snapshot, blobs)| build_rpc_block(snapshot.beacon_block.clone(), blobs)) + .filter(|(snapshot, _)| snapshot.beacon_block.slot() > finalized_slot) + .map(|(snapshot, blobs)| { + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + }) .collect(); // We don't care if this fails, we just call this to ensure that all prior blocks have been // imported prior to this test. @@ -494,15 +611,17 @@ async fn assert_invalid_signature( .chain .process_chain_segment(ancestor_blocks, NotifyExecutionLayer::Yes) .await; + update_fork_choice_with_envelopes(chain_segment, harness); harness.chain.recompute_head_at_current_slot().await; let process_res = harness .chain .process_block( snapshots[block_index].beacon_block.canonical_root(), - build_rpc_block( + build_range_sync_block( snapshots[block_index].beacon_block.clone(), &chain_segment_blobs[block_index], + harness.chain.clone(), ), NotifyExecutionLayer::Yes, BlockImportSource::Lookup, @@ -533,6 +652,7 @@ async fn get_invalid_sigs_harness( chain_segment: &[BeaconSnapshot<E>], ) -> BeaconChainHarness<EphemeralHarnessType<E>> { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); + store_envelopes_for_chain_segment(chain_segment, &harness); harness .chain .slot_clock @@ -560,7 +680,9 @@ async fn invalid_signature_gossip_block() { .iter() .take(block_index) .zip(chain_segment_blobs.iter()) - .map(|(snapshot, blobs)| build_rpc_block(snapshot.beacon_block.clone(), blobs)) + .map(|(snapshot, blobs)| { + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + }) .collect(); harness .chain @@ -569,12 +691,12 @@ async fn invalid_signature_gossip_block() { .into_block_error() .expect("should import all blocks prior to the one being tested"); let signed_block = SignedBeaconBlock::from_block(block, junk_signature()); - let rpc_block = RpcBlock::new_without_blobs(None, Arc::new(signed_block)); + let lookup_block = LookupBlock::new(Arc::new(signed_block)); let process_res = harness .chain .process_block( - rpc_block.block_root(), - rpc_block, + lookup_block.block_root(), + lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -608,10 +730,12 @@ async fn invalid_signature_block_proposal() { block.clone(), junk_signature(), )); - let blocks: Vec<RpcBlock<E>> = snapshots + let blocks: Vec<RangeSyncBlock<E>> = snapshots .iter() .zip(chain_segment_blobs.iter()) - .map(|(snapshot, blobs)| build_rpc_block(snapshot.beacon_block.clone(), blobs)) + .map(|(snapshot, blobs)| { + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + }) .collect::<Vec<_>>(); // Ensure the block will be rejected if imported in a chain segment. let process_res = harness @@ -934,10 +1058,12 @@ async fn invalid_signature_deposit() { Arc::new(SignedBeaconBlock::from_block(block, signature)); update_parent_roots(&mut snapshots, &mut chain_segment_blobs); update_proposal_signatures(&mut snapshots, &harness); - let blocks: Vec<RpcBlock<E>> = snapshots + let blocks: Vec<RangeSyncBlock<E>> = snapshots .iter() .zip(chain_segment_blobs.iter()) - .map(|(snapshot, blobs)| build_rpc_block(snapshot.beacon_block.clone(), blobs)) + .map(|(snapshot, blobs)| { + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + }) .collect(); assert!( !matches!( @@ -1033,6 +1159,21 @@ async fn block_gossip_verification() { ) .await .expect("should import valid gossip verified block"); + // Post-Gloas, store the execution payload envelope so that subsequent blocks can look up + // the parent envelope. + if let Some(ref envelope) = snapshot.execution_envelope { + harness + .chain + .store + .put_payload_envelope(&snapshot.beacon_block_root, envelope) + .expect("should store envelope"); + harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(snapshot.beacon_block_root) + .expect("should update fork choice with envelope"); + } if let Some(data_sidecars) = blobs_opt { verify_and_process_gossip_data_sidecars(&harness, data_sidecars).await; } @@ -1579,13 +1720,19 @@ async fn add_base_block_to_altair_chain() { )); // Ensure that it would be impossible to import via `BeaconChain::process_block`. - let base_rpc_block = RpcBlock::new_without_blobs(None, Arc::new(base_block.clone())); + let base_range_sync_block = RangeSyncBlock::new( + Arc::new(base_block.clone()), + AvailableBlockData::NoData, + &harness.chain.data_availability_checker, + harness.spec.clone(), + ) + .unwrap(); assert!(matches!( harness .chain .process_block( - base_rpc_block.block_root(), - base_rpc_block, + base_range_sync_block.block_root(), + base_range_sync_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -1603,7 +1750,15 @@ async fn add_base_block_to_altair_chain() { harness .chain .process_chain_segment( - vec![RpcBlock::new_without_blobs(None, Arc::new(base_block))], + vec![ + RangeSyncBlock::new( + Arc::new(base_block), + AvailableBlockData::NoData, + &harness.chain.data_availability_checker, + harness.spec.clone() + ) + .unwrap() + ], NotifyExecutionLayer::Yes, ) .await, @@ -1716,13 +1871,13 @@ async fn add_altair_block_to_base_chain() { )); // Ensure that it would be impossible to import via `BeaconChain::process_block`. - let altair_rpc_block = RpcBlock::new_without_blobs(None, Arc::new(altair_block.clone())); + let altair_lookup_block = LookupBlock::new(Arc::new(altair_block.clone())); assert!(matches!( harness .chain .process_block( - altair_rpc_block.block_root(), - altair_rpc_block, + altair_lookup_block.block_root(), + altair_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -1740,7 +1895,15 @@ async fn add_altair_block_to_base_chain() { harness .chain .process_chain_segment( - vec![RpcBlock::new_without_blobs(None, Arc::new(altair_block))], + vec![ + RangeSyncBlock::new( + Arc::new(altair_block), + AvailableBlockData::NoData, + &harness.chain.data_availability_checker, + harness.spec.clone() + ) + .unwrap() + ], NotifyExecutionLayer::Yes ) .await, @@ -1758,10 +1921,8 @@ async fn add_altair_block_to_base_chain() { // https://github.com/sigp/lighthouse/issues/4332#issuecomment-1565092279 #[tokio::test] async fn import_duplicate_block_unrealized_justification() { - let spec = MainnetEthSpec::default_spec(); - let harness = BeaconChainHarness::builder(MainnetEthSpec) - .spec(spec.into()) + .default_spec() .keypairs(KEYPAIRS[..].to_vec()) .fresh_ephemeral_store() .mock_execution_layer() @@ -1803,12 +1964,18 @@ async fn import_duplicate_block_unrealized_justification() { // Create two verified variants of the block, representing the same block being processed in // parallel. let notify_execution_layer = NotifyExecutionLayer::Yes; - let rpc_block = RpcBlock::new_without_blobs(Some(block_root), block.clone()); - let verified_block1 = rpc_block + let range_sync_block = RangeSyncBlock::new( + block.clone(), + AvailableBlockData::NoData, + &harness.chain.data_availability_checker, + harness.spec.clone(), + ) + .unwrap(); + let verified_block1 = range_sync_block .clone() .into_execution_pending_block(block_root, chain, notify_execution_layer) .unwrap(); - let verified_block2 = rpc_block + let verified_block2 = range_sync_block .into_execution_pending_block(block_root, chain, notify_execution_layer) .unwrap(); @@ -1877,3 +2044,244 @@ async fn import_execution_pending_block<T: BeaconChainTypes>( } } } + +// Test that RpcBlock::new() rejects blocks when blob count doesn't match expected. +#[tokio::test] +async fn range_sync_block_construction_fails_with_wrong_blob_count() { + let spec = test_spec::<E>(); + + if !spec.fork_name_at_slot::<E>(Slot::new(0)).deneb_enabled() + || spec.fork_name_at_slot::<E>(Slot::new(0)).fulu_enabled() + { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.into()) + .keypairs(KEYPAIRS[0..VALIDATOR_COUNT].to_vec()) + .node_custody_type(NodeCustodyType::Fullnode) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.advance_slot(); + + harness + .extend_chain( + E::slots_per_epoch() as usize * 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Get a block with blobs + for slot in 1..=5 { + let root = harness + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + let block = harness.chain.get_block(&root).await.unwrap().unwrap(); + + if let Ok(commitments) = block.message().body().blob_kzg_commitments() + && !commitments.is_empty() + { + let blobs = harness.chain.get_blobs(&root).unwrap().blobs().unwrap(); + + // Create AvailableBlockData with wrong number of blobs (remove one) + let mut wrong_blobs_vec: Vec<_> = blobs.iter().cloned().collect(); + wrong_blobs_vec.pop(); + + let max_blobs = harness.spec.max_blobs_per_block(block.epoch()) as usize; + let wrong_blobs = ssz_types::RuntimeVariableList::new(wrong_blobs_vec, max_blobs) + .expect("should create BlobSidecarList"); + let block_data = AvailableBlockData::new_with_blobs(wrong_blobs); + + // Try to create RpcBlock with wrong blob count + let result = RangeSyncBlock::new( + Arc::new(block), + block_data, + &harness.chain.data_availability_checker, + harness.chain.spec.clone(), + ); + + // Should fail with MissingBlobs + assert!( + matches!(result, Err(AvailabilityCheckError::MissingBlobs)), + "RpcBlock construction should fail with wrong blob count, got: {:?}", + result + ); + return; + } + } + + panic!("No block with blobs found"); +} + +// Test that RpcBlock::new() rejects blocks when custody columns are incomplete. +#[tokio::test] +async fn range_sync_block_rejects_missing_custody_columns() { + let spec = test_spec::<E>(); + + // Gloas blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if !spec.fork_name_at_slot::<E>(Slot::new(0)).fulu_enabled() + || spec.fork_name_at_slot::<E>(Slot::new(0)).gloas_enabled() + { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.into()) + .keypairs(KEYPAIRS[0..VALIDATOR_COUNT].to_vec()) + .node_custody_type(NodeCustodyType::Fullnode) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.advance_slot(); + + // Extend chain to create some blocks with data columns + harness + .extend_chain( + 5, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Get a block with data columns + for slot in 1..=5 { + let root = harness + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + let block = harness.chain.get_block(&root).await.unwrap().unwrap(); + + if let Ok(commitments) = block.message().body().blob_kzg_commitments() + && !commitments.is_empty() + { + let fork_name = harness.chain.spec.fork_name_at_slot::<E>(block.slot()); + let columns = harness + .chain + .get_data_columns(&root, fork_name) + .unwrap() + .unwrap(); + + if columns.len() > 1 { + // Create AvailableBlockData with incomplete columns (remove one) + let mut incomplete_columns: Vec<_> = columns.to_vec(); + incomplete_columns.pop(); + + let block_data = AvailableBlockData::new_with_data_columns(incomplete_columns); + + // Try to create RpcBlock with incomplete custody columns + let result = RangeSyncBlock::new( + Arc::new(block), + block_data, + &harness.chain.data_availability_checker, + harness.chain.spec.clone(), + ); + + // Should fail with MissingCustodyColumns + assert!( + matches!(result, Err(AvailabilityCheckError::MissingCustodyColumns)), + "RpcBlock construction should fail with missing custody columns, got: {:?}", + result + ); + return; + } + } + } + + panic!("No block with data columns found"); +} + +// Test that RpcBlock::new() allows construction past the data availability boundary. +// When a block is past the DA boundary, we should be able to construct an RpcBlock +// with NoData even if the block has blob commitments, since columns are not expected. +#[tokio::test] +async fn rpc_block_allows_construction_past_da_boundary() { + let spec = test_spec::<E>(); + + // Gloas blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if !spec.fork_name_at_slot::<E>(Slot::new(0)).fulu_enabled() + || spec.fork_name_at_slot::<E>(Slot::new(0)).gloas_enabled() + { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.into()) + .keypairs(KEYPAIRS[0..VALIDATOR_COUNT].to_vec()) + .node_custody_type(NodeCustodyType::Fullnode) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.advance_slot(); + + // Extend chain to create some blocks with blob commitments + harness + .extend_chain( + 5, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Find a block with blob commitments + for slot in 1..=5 { + let root = harness + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + let block = harness.chain.get_block(&root).await.unwrap().unwrap(); + + if let Ok(commitments) = block.message().body().blob_kzg_commitments() + && !commitments.is_empty() + { + let block_epoch = block.epoch(); + + // Advance the slot clock far into the future, past the DA boundary + // For a block to be past the DA boundary: + // current_epoch - min_epochs_for_data_column_sidecars_requests > block_epoch + let min_epochs_for_data = harness.spec.min_epochs_for_data_column_sidecars_requests; + let future_epoch = block_epoch + min_epochs_for_data + 10; + let future_slot = future_epoch.start_slot(E::slots_per_epoch()); + harness.chain.slot_clock.set_slot(future_slot.as_u64()); + + // Now verify the block is past the DA boundary + let da_boundary = harness + .chain + .data_availability_checker + .data_availability_boundary() + .expect("DA boundary should be set"); + assert!( + block_epoch < da_boundary, + "Block should be past the DA boundary. Block epoch: {}, DA boundary: {}", + block_epoch, + da_boundary + ); + + // Try to create RpcBlock with NoData for a block past DA boundary + // This should succeed since columns are not expected for blocks past DA boundary + let result = RangeSyncBlock::new( + Arc::new(block), + AvailableBlockData::NoData, + &harness.chain.data_availability_checker, + harness.chain.spec.clone(), + ); + + assert!( + result.is_ok(), + "RpcBlock construction should succeed for blocks past DA boundary, got: {:?}", + result + ); + return; + } + } + + panic!("No block with blob commitments found"); +} diff --git a/beacon_node/beacon_chain/tests/capella.rs b/beacon_node/beacon_chain/tests/capella.rs deleted file mode 100644 index 2c2ba8e01a..0000000000 --- a/beacon_node/beacon_chain/tests/capella.rs +++ /dev/null @@ -1,156 +0,0 @@ -#![cfg(not(debug_assertions))] // Tests run too slow in debug. - -use beacon_chain::test_utils::BeaconChainHarness; -use execution_layer::test_utils::Block; -use types::*; - -const VALIDATOR_COUNT: usize = 32; -type E = MainnetEthSpec; - -fn verify_execution_payload_chain<E: EthSpec>(chain: &[FullPayload<E>]) { - let mut prev_ep: Option<FullPayload<E>> = None; - - for ep in chain { - assert!(!ep.is_default_with_empty_roots()); - assert!(ep.block_hash() != ExecutionBlockHash::zero()); - - // Check against previous `ExecutionPayload`. - if let Some(prev_ep) = prev_ep { - assert_eq!(prev_ep.block_hash(), ep.parent_hash()); - assert_eq!(prev_ep.block_number() + 1, ep.block_number()); - assert!(ep.timestamp() > prev_ep.timestamp()); - } - prev_ep = Some(ep.clone()); - } -} - -#[tokio::test] -async fn base_altair_bellatrix_capella() { - let altair_fork_epoch = Epoch::new(4); - let altair_fork_slot = altair_fork_epoch.start_slot(E::slots_per_epoch()); - let bellatrix_fork_epoch = Epoch::new(8); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(E::slots_per_epoch()); - let capella_fork_epoch = Epoch::new(12); - let capella_fork_slot = capella_fork_epoch.start_slot(E::slots_per_epoch()); - - let mut spec = E::default_spec(); - spec.altair_fork_epoch = Some(altair_fork_epoch); - spec.bellatrix_fork_epoch = Some(bellatrix_fork_epoch); - spec.capella_fork_epoch = Some(capella_fork_epoch); - - let harness = BeaconChainHarness::builder(E::default()) - .spec(spec.into()) - .deterministic_keypairs(VALIDATOR_COUNT) - .fresh_ephemeral_store() - .mock_execution_layer() - .build(); - - /* - * Start with the base fork. - */ - assert!(harness.chain.head_snapshot().beacon_block.as_base().is_ok()); - - /* - * Do the Altair fork. - */ - Box::pin(harness.extend_to_slot(altair_fork_slot)).await; - - let altair_head = &harness.chain.head_snapshot().beacon_block; - assert!(altair_head.as_altair().is_ok()); - assert_eq!(altair_head.slot(), altair_fork_slot); - - /* - * Do the Bellatrix fork, without a terminal PoW block. - */ - Box::pin(harness.extend_to_slot(bellatrix_fork_slot)).await; - - let bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!(bellatrix_head.as_bellatrix().is_ok()); - assert_eq!(bellatrix_head.slot(), bellatrix_fork_slot); - assert!( - bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Bellatrix head is default payload" - ); - - /* - * Next Bellatrix block shouldn't include an exec payload. - */ - Box::pin(harness.extend_slots(1)).await; - - let one_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - one_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "One after bellatrix head is default payload" - ); - assert_eq!(one_after_bellatrix_head.slot(), bellatrix_fork_slot + 1); - - /* - * Trigger the terminal PoW block. - */ - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - - // Add a slot duration to get to the next slot - let timestamp = harness.get_timestamp_at_slot() + harness.spec.seconds_per_slot; - harness - .execution_block_generator() - .modify_last_block(|block| { - if let Block::PoW(terminal_block) = block { - terminal_block.timestamp = timestamp; - } - }); - Box::pin(harness.extend_slots(1)).await; - - let two_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - two_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Two after bellatrix head is default payload" - ); - assert_eq!(two_after_bellatrix_head.slot(), bellatrix_fork_slot + 2); - - /* - * Next Bellatrix block should include an exec payload. - */ - let mut execution_payloads = vec![]; - for _ in (bellatrix_fork_slot.as_u64() + 3)..capella_fork_slot.as_u64() { - harness.extend_slots(1).await; - let block = &harness.chain.head_snapshot().beacon_block; - let full_payload: FullPayload<E> = - block.message().body().execution_payload().unwrap().into(); - // pre-capella shouldn't have withdrawals - assert!(full_payload.withdrawals_root().is_err()); - execution_payloads.push(full_payload); - } - - /* - * Should enter capella fork now. - */ - for _ in 0..16 { - harness.extend_slots(1).await; - let block = &harness.chain.head_snapshot().beacon_block; - let full_payload: FullPayload<E> = - block.message().body().execution_payload().unwrap().into(); - // post-capella should have withdrawals - assert!(full_payload.withdrawals_root().is_ok()); - execution_payloads.push(full_payload); - } - - verify_execution_payload_chain(execution_payloads.as_slice()); -} diff --git a/beacon_node/beacon_chain/tests/column_verification.rs b/beacon_node/beacon_chain/tests/column_verification.rs index be9b3b2fa1..06a5f44e5f 100644 --- a/beacon_node/beacon_chain/tests/column_verification.rs +++ b/beacon_node/beacon_chain/tests/column_verification.rs @@ -7,7 +7,7 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{ AvailabilityProcessingStatus, BlockError, ChainConfig, InvalidSignature, NotifyExecutionLayer, - block_verification_types::AsBlock, + block_verification_types::{AsBlock, LookupBlock}, }; use bls::{Keypair, Signature}; use logging::create_test_tracing_subscriber; @@ -16,8 +16,8 @@ use types::*; type E = MainnetEthSpec; -// Should ideally be divisible by 3. -const VALIDATOR_COUNT: usize = 24; +// >= 32 validators required for Gloas genesis with MainnetEthSpec (32 slots/epoch). +const VALIDATOR_COUNT: usize = 32; /// A cached set of keys. static KEYPAIRS: LazyLock<Vec<Keypair>> = @@ -32,7 +32,7 @@ fn get_harness( let harness = BeaconChainHarness::builder(MainnetEthSpec) .spec(spec) .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .keypairs(KEYPAIRS[0..validator_count].to_vec()) @@ -52,7 +52,8 @@ async fn rpc_columns_with_invalid_header_signature() { let spec = Arc::new(test_spec::<E>()); // Only run this test if columns are enabled. - if !spec.is_fulu_scheduled() { + // TODO(gloas): Gloas blocks don't have blob_kzg_commitments — blobs are in the envelope. + if !spec.is_fulu_scheduled() || spec.is_gloas_scheduled() { return; } @@ -80,16 +81,13 @@ async fn rpc_columns_with_invalid_header_signature() { // Process the block without blobs so that it doesn't become available. harness.advance_slot(); - let rpc_block = harness - .build_rpc_block_from_blobs(block_root, signed_block.clone(), None) - .unwrap(); let availability = harness .chain .process_block( block_root, - rpc_block, + LookupBlock::new(signed_block.clone()), NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, + BlockImportSource::Lookup, || Ok(()), ) .await @@ -116,3 +114,165 @@ async fn rpc_columns_with_invalid_header_signature() { BlockError::InvalidSignature(InvalidSignature::ProposerSignature) )); } + +/// Test that Gloas block production caches blobs alongside the envelope, and that +/// data columns can be built from those cached blobs. +#[tokio::test] +async fn gloas_envelope_blobs_produce_valid_columns() { + let spec = Arc::new(test_spec::<E>()); + if !spec.is_gloas_scheduled() { + return; + } + + let harness = get_harness(VALIDATOR_COUNT, spec.clone(), NodeCustodyType::Supernode); + harness.execution_block_generator().set_min_blob_count(1); + + // Build some chain depth. + let num_blocks = E::slots_per_epoch() as usize; + harness + .extend_chain( + num_blocks, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + harness.advance_slot(); + let slot = harness.get_current_slot(); + + // Produce a Gloas block via the harness. This caches envelope + blobs. + let state = harness.get_current_state(); + let (block_contents, opt_envelope, _post_state) = + harness.make_block_with_envelope(state, slot).await; + let signed_block = &block_contents.0; + + assert!( + opt_envelope.is_some(), + "Gloas block production should produce an envelope" + ); + + // Verify the block has blob commitments in the bid. + let bid = signed_block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid"); + assert!( + !bid.message.blob_kzg_commitments.is_empty(), + "Block should have blob KZG commitments" + ); + + // Generate data columns from the block (using test fixtures, same as the harness does). + let data_column_sidecars = + generate_data_column_sidecars_from_block(signed_block, &harness.chain.spec); + assert_eq!( + data_column_sidecars.len(), + E::number_of_columns(), + "Should produce the correct number of data columns" + ); + + // Verify all columns are Gloas-format. + for col in &data_column_sidecars { + assert!( + col.as_gloas().is_ok(), + "Data column sidecar should be Gloas variant" + ); + let gloas_col = col.as_gloas().expect("should be Gloas sidecar"); + assert_eq!(gloas_col.beacon_block_root, signed_block.canonical_root()); + assert_eq!(gloas_col.slot, slot); + } + + // End-to-end DA flow (process_block → process_envelope → process_rpc_custody_columns) + // is not exercised here: Gloas blocks are not gated on columns at block-import time + // and the envelope/column gating belongs in a dedicated test once the DA path matures. +} + +// Regression test for verify_header_signature bug: it uses head_fork() which is wrong for fork blocks +#[tokio::test] +async fn verify_header_signature_fork_block_bug() { + // Create a spec with all forks enabled at genesis except Fulu which is at epoch 1 + // This allows us to easily create the scenario where the head is at Electra + // but we're trying to verify a block from Fulu epoch + let mut spec = test_spec::<E>(); + + // Only run this test for FORK_NAME=fulu. + if !spec.is_fulu_scheduled() || spec.is_gloas_scheduled() { + return; + } + + spec.altair_fork_epoch = Some(Epoch::new(0)); + spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + spec.capella_fork_epoch = Some(Epoch::new(0)); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.electra_fork_epoch = Some(Epoch::new(0)); + let fulu_fork_epoch = Epoch::new(1); + spec.fulu_fork_epoch = Some(fulu_fork_epoch); + + let spec = Arc::new(spec); + let harness = get_harness(VALIDATOR_COUNT, spec.clone(), NodeCustodyType::Supernode); + harness.execution_block_generator().set_min_blob_count(1); + + // Add some blocks in epoch 0 (Electra) + harness + .extend_chain( + E::slots_per_epoch() as usize - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Verify we're still in epoch 0 (Electra) + let pre_fork_state = harness.get_current_state(); + assert_eq!(pre_fork_state.current_epoch(), Epoch::new(0)); + assert!(matches!(pre_fork_state, BeaconState::Electra(_))); + + // Now produce a block at the first slot of epoch 1 (Fulu fork). + // make_block will advance the state which will trigger the Electra->Fulu upgrade. + let fork_slot = fulu_fork_epoch.start_slot(E::slots_per_epoch()); + let ((signed_block, opt_blobs), _state_root) = + harness.make_block(pre_fork_state.clone(), fork_slot).await; + let (_, blobs) = opt_blobs.expect("Blobs should be present"); + assert!(!blobs.is_empty(), "Block should have blobs"); + let block_root = signed_block.canonical_root(); + + // Process the block WITHOUT blobs to make it unavailable. + // The block will be accepted but won't become the head because it's not fully available. + // This keeps the head at the pre-fork state (Electra). + harness.advance_slot(); + let availability = harness + .chain + .process_block( + block_root, + LookupBlock::new(signed_block.clone()), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await + .expect("Block should be processed"); + assert_eq!( + availability, + AvailabilityProcessingStatus::MissingComponents(fork_slot, block_root), + "Block should be pending availability" + ); + + // The head should still be in epoch 0 (Electra) because the fork block isn't available + let current_head_state = harness.get_current_state(); + assert_eq!(current_head_state.current_epoch(), Epoch::new(0)); + assert!(matches!(current_head_state, BeaconState::Electra(_))); + + // Now try to process columns for the fork block. + // The bug: verify_header_signature previously used head_fork() which fetched the fork from + // the head state (still Electra fork), but the block was signed with the Fulu fork version. + // This caused an incorrect signature verification failure. + let data_column_sidecars = + generate_data_column_sidecars_from_block(&signed_block, &harness.chain.spec); + + // Now that the bug is fixed, the block should import. + let status = harness + .chain + .process_rpc_custody_columns(data_column_sidecars) + .await + .unwrap(); + assert_eq!(status, AvailabilityProcessingStatus::Imported(block_root)); +} diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index 86bdb03daf..e943514c4e 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -7,9 +7,12 @@ use eth2::types::{EventKind, SseBlobSidecar, SseDataColumnSidecar}; use rand::SeedableRng; use rand::rngs::StdRng; use std::sync::Arc; -use types::blob_sidecar::FixedBlobSidecarList; +use types::data::FixedBlobSidecarList; use types::test_utils::TestRandom; -use types::{BlobSidecar, DataColumnSidecar, EthSpec, MinimalEthSpec, Slot}; +use types::{ + BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, EthSpec, + MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedRoot, Slot, +}; type E = MinimalEthSpec; @@ -73,13 +76,22 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { // build and process a gossip verified data column let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); let sidecar = { - // DA checker only accepts sampling columns, so we need to create one with a sampling index. - let mut random_sidecar = DataColumnSidecar::random_for_test(&mut rng); let slot = Slot::new(10); - let epoch = slot.epoch(E::slots_per_epoch()); - random_sidecar.signed_block_header.message.slot = slot; - random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; - random_sidecar + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + // DA checker only accepts sampling columns, so we need to create one with a sampling index. + if fork_name.gloas_enabled() { + let mut random_sidecar = DataColumnSidecarGloas::random_for_test(&mut rng); + let epoch = slot.epoch(E::slots_per_epoch()); + random_sidecar.slot = slot; + random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + DataColumnSidecar::Gloas(random_sidecar) + } else { + let mut random_sidecar = DataColumnSidecarFulu::random_for_test(&mut rng); + let epoch = slot.epoch(E::slots_per_epoch()); + random_sidecar.signed_block_header.message.slot = slot; + random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + DataColumnSidecar::Fulu(random_sidecar) + } }; let gossip_verified_data_column = GossipVerifiedDataColumn::__new_for_testing(Arc::new(sidecar)); @@ -103,7 +115,7 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { /// Verifies that a blob event is emitted when blobs are received via RPC. #[tokio::test] async fn blob_sidecar_event_on_process_rpc_blobs() { - if fork_name_from_env().is_some_and(|f| !f.deneb_enabled() || f.fulu_enabled()) { + if fork_name_from_env().is_none_or(|f| !f.deneb_enabled() || f.fulu_enabled()) { return; }; @@ -158,7 +170,10 @@ async fn blob_sidecar_event_on_process_rpc_blobs() { #[tokio::test] async fn data_column_sidecar_event_on_process_rpc_columns() { - if fork_name_from_env().is_some_and(|f| !f.fulu_enabled()) { + // Gloas blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if fork_name_from_env().is_none_or(|f| !f.fulu_enabled()) + || fork_name_from_env().is_some_and(|f| f.gloas_enabled()) + { return; }; @@ -201,3 +216,219 @@ async fn data_column_sidecar_event_on_process_rpc_columns() { EventKind::DataColumnSidecar(expected_sse_data_column) ); } + +/// Verifies that a head event is emitted when a block is imported and becomes the head. +#[tokio::test] +async fn head_event_on_block_import() { + let spec = Arc::new(test_spec::<E>()); + let harness = BeaconChainHarness::builder(E::default()) + .spec(spec.clone()) + .deterministic_keypairs(8) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + // Subscribe to head events before importing the block + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut head_event_receiver = event_handler.subscribe_head(); + + // Build and process a block that will become the new head + let head_state = harness.get_current_state(); + let target_slot = head_state.slot() + 1; + harness.advance_slot(); + let ((signed_block, blobs), _) = harness.make_block(head_state, target_slot).await; + + let block_root = signed_block.canonical_root(); + let state_root = signed_block.message().state_root(); + + harness + .process_block(target_slot, block_root, (signed_block, blobs)) + .await + .unwrap(); + + // Verify the head event was emitted with correct data + let head_event = head_event_receiver.try_recv().unwrap(); + if let EventKind::Head(sse_head) = head_event { + assert_eq!(sse_head.slot, target_slot); + assert_eq!(sse_head.block, block_root); + assert_eq!(sse_head.state, state_root); + // execution_optimistic should be false since we're using mock execution layer + assert!(!sse_head.execution_optimistic); + } else { + panic!("Expected Head event, got {:?}", head_event); + } +} + +/// Verifies that `execution_payload_gossip` fires at gossip verification time, and +/// `execution_payload` + `execution_payload_available` fire at import time. +#[tokio::test] +async fn execution_payload_envelope_events() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.extend_to_slot(Slot::new(1)).await; + + let state = harness.get_current_state(); + let target_slot = Slot::new(2); + harness.advance_slot(); + let (block_contents, opt_envelope, _new_state) = + harness.make_block_with_envelope(state, target_slot).await; + + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(target_slot, block_root, block_contents) + .await + .expect("block should be processed"); + + let signed_envelope = opt_envelope.expect("Gloas block should produce an envelope"); + + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut gossip_receiver = event_handler.subscribe_execution_payload_gossip(); + let mut payload_receiver = event_handler.subscribe_execution_payload(); + let mut available_receiver = event_handler.subscribe_execution_payload_available(); + + // Stage 1: gossip verification fires execution_payload_gossip only. + let gossip_verified = harness + .chain + .verify_envelope_for_gossip(Arc::new(signed_envelope)) + .await + .expect("envelope gossip verification should succeed"); + + let gossip_event = gossip_receiver + .try_recv() + .expect("should receive execution_payload_gossip after gossip verification"); + if let EventKind::ExecutionPayloadGossip(sse) = gossip_event { + assert_eq!(sse.slot, target_slot); + assert_eq!(sse.block_root, block_root); + } else { + panic!( + "Expected ExecutionPayloadGossip event, got {:?}", + gossip_event + ); + } + assert!(payload_receiver.try_recv().is_err()); + assert!(available_receiver.try_recv().is_err()); + + // Stage 2: import fires execution_payload and execution_payload_available. + harness + .chain + .process_execution_payload_envelope( + block_root, + gossip_verified, + beacon_chain::NotifyExecutionLayer::Yes, + types::BlockImportSource::Gossip, + #[allow(clippy::result_large_err)] + || Ok(()), + ) + .await + .expect("envelope import should succeed"); + + let payload_event = payload_receiver + .try_recv() + .expect("should receive execution_payload after import"); + if let EventKind::ExecutionPayload(sse) = payload_event { + assert_eq!(sse.slot, target_slot); + assert_eq!(sse.block_root, block_root); + } else { + panic!("Expected ExecutionPayload event, got {:?}", payload_event); + } + + let available_event = available_receiver + .try_recv() + .expect("should receive execution_payload_available after import"); + if let EventKind::ExecutionPayloadAvailable(sse) = available_event { + assert_eq!(sse.slot, target_slot); + assert_eq!(sse.block_root, block_root); + } else { + panic!( + "Expected ExecutionPayloadAvailable event, got {:?}", + available_event + ); + } + + assert!( + gossip_receiver.try_recv().is_err(), + "no extra gossip events should fire during import" + ); +} + +/// Verifies that a `payload_attestation_message` event is emitted when a payload attestation +/// message passes gossip verification. +#[tokio::test] +async fn payload_attestation_message_event_on_gossip_verification() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + // Advance chain to have a valid head block. + let target_slot = Slot::new(1); + harness.extend_to_slot(target_slot).await; + + let head = harness.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + + // Get a PTC member for this slot. + let ptc = head_state + .get_ptc(target_slot, &harness.spec) + .expect("should get PTC"); + let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; + + // Sign a payload attestation. + let target_epoch = target_slot.epoch(E::slots_per_epoch()); + let domain = harness.spec.get_domain( + target_epoch, + Domain::PTCAttester, + &head_state.fork(), + head_state.genesis_validators_root(), + ); + let data = PayloadAttestationData { + beacon_block_root: head.head_block_root(), + slot: target_slot, + payload_present: true, + blob_data_available: true, + }; + let message = data.signing_root(domain); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(message); + let msg = PayloadAttestationMessage { + validator_index, + data: data.clone(), + signature: signature.clone(), + }; + + // Subscribe before verification. + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut receiver = event_handler.subscribe_payload_attestation_message(); + + // Verify the attestation through the gossip path. + harness + .chain + .verify_payload_attestation_message_for_gossip(msg) + .expect("verification should succeed"); + + // Assert the event was emitted. + let event = receiver.try_recv().expect("should receive event"); + if let EventKind::PayloadAttestationMessage(versioned) = event { + assert_eq!(versioned.data.validator_index, validator_index); + assert_eq!(versioned.data.data, data); + } else { + panic!("Expected PayloadAttestationMessage event, got {:?}", event); + } +} diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index 66b8b85541..74115576e7 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -1,14 +1,13 @@ mod attestation_production; mod attestation_verification; -mod bellatrix; mod blob_verification; mod block_verification; -mod capella; mod column_verification; mod events; mod inclusion_list_verification; mod op_verification; mod payload_invalidation; +mod prepare_payload; mod rewards; mod schema_stability; mod store_tests; diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 5bd43835e3..be85fc2245 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1,12 +1,13 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::{ BeaconChainError, BlockError, ChainConfig, ExecutionPayloadError, - INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, OverrideForkchoiceUpdate, - StateSkipConfig, WhenSlotSkipped, + INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, StateSkipConfig, + WhenSlotSkipped, canonical_head::{CachedHead, CanonicalHead}, - test_utils::{BeaconChainHarness, EphemeralHarnessType}, + test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, }; use execution_layer::{ ExecutionLayer, ForkchoiceState, PayloadAttributes, @@ -42,18 +43,15 @@ struct InvalidPayloadRig { impl InvalidPayloadRig { fn new() -> Self { - let spec = E::default_spec(); + let spec = test_spec::<E>(); Self::new_with_spec(spec) } - fn new_with_spec(mut spec: ChainSpec) -> Self { - spec.altair_fork_epoch = Some(Epoch::new(0)); - spec.bellatrix_fork_epoch = Some(Epoch::new(0)); - + fn new_with_spec(spec: ChainSpec) -> Self { let harness = BeaconChainHarness::builder(MainnetEthSpec) .spec(spec.into()) .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .deterministic_keypairs(VALIDATOR_COUNT) @@ -141,25 +139,6 @@ impl InvalidPayloadRig { payload_attributes } - fn move_to_terminal_block(&self) { - let mock_execution_layer = self.harness.mock_execution_layer.as_ref().unwrap(); - mock_execution_layer - .server - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - } - - fn latest_execution_block_hash(&self) -> ExecutionBlockHash { - let mock_execution_layer = self.harness.mock_execution_layer.as_ref().unwrap(); - mock_execution_layer - .server - .execution_block_generator() - .latest_execution_block() - .unwrap() - .block_hash - } - async fn build_blocks(&mut self, num_blocks: u64, is_valid: Payload) -> Vec<Hash256> { let mut roots = Vec::with_capacity(num_blocks as usize); for _ in 0..num_blocks { @@ -392,8 +371,10 @@ impl InvalidPayloadRig { /// Simple test of the different import types. #[tokio::test] async fn valid_invalid_syncing() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; rig.import_block(Payload::Invalid { @@ -407,8 +388,10 @@ async fn valid_invalid_syncing() { /// `latest_valid_hash`. #[tokio::test] async fn invalid_payload_invalidates_parent() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. rig.move_to_first_justification(Payload::Syncing).await; @@ -440,7 +423,6 @@ async fn immediate_forkchoice_update_invalid_test( invalid_payload: impl FnOnce(Option<ExecutionBlockHash>) -> Payload, ) { let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. rig.move_to_first_justification(Payload::Syncing).await; @@ -463,6 +445,9 @@ async fn immediate_forkchoice_update_invalid_test( #[tokio::test] async fn immediate_forkchoice_update_payload_invalid() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } immediate_forkchoice_update_invalid_test(|latest_valid_hash| Payload::Invalid { latest_valid_hash, }) @@ -471,11 +456,17 @@ async fn immediate_forkchoice_update_payload_invalid() { #[tokio::test] async fn immediate_forkchoice_update_payload_invalid_block_hash() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } immediate_forkchoice_update_invalid_test(|_| Payload::InvalidBlockHash).await } #[tokio::test] async fn immediate_forkchoice_update_payload_invalid_terminal_block() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } immediate_forkchoice_update_invalid_test(|_| Payload::Invalid { latest_valid_hash: Some(ExecutionBlockHash::zero()), }) @@ -485,8 +476,10 @@ async fn immediate_forkchoice_update_payload_invalid_terminal_block() { /// Ensure the client tries to exit when the justified checkpoint is invalidated. #[tokio::test] async fn justified_checkpoint_becomes_invalid() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. rig.move_to_first_justification(Payload::Syncing).await; @@ -527,11 +520,13 @@ async fn justified_checkpoint_becomes_invalid() { /// Ensure that a `latest_valid_hash` for a pre-finality block only reverts a single block. #[tokio::test] async fn pre_finalized_latest_valid_hash() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 4; let finalized_epoch = 2; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); let mut blocks = vec![]; blocks.push(rig.import_block(Payload::Valid).await); // Import a valid transition block. blocks.extend(rig.build_blocks(num_blocks - 1, Payload::Syncing).await); @@ -574,10 +569,12 @@ async fn pre_finalized_latest_valid_hash() { /// - Will not validate `latest_valid_root` and its ancestors. #[tokio::test] async fn latest_valid_hash_will_not_validate() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } const LATEST_VALID_SLOT: u64 = 3; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); let mut blocks = vec![]; blocks.push(rig.import_block(Payload::Valid).await); // Import a valid transition block. @@ -621,11 +618,13 @@ async fn latest_valid_hash_will_not_validate() { /// Check behaviour when the `latest_valid_hash` is a junk value. #[tokio::test] async fn latest_valid_hash_is_junk() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 5; let finalized_epoch = 3; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); let mut blocks = vec![]; blocks.push(rig.import_block(Payload::Valid).await); // Import a valid transition block. blocks.extend(rig.build_blocks(num_blocks, Payload::Syncing).await); @@ -662,12 +661,14 @@ async fn latest_valid_hash_is_junk() { /// Check that descendants of invalid blocks are also invalidated. #[tokio::test] async fn invalidates_all_descendants() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 4 + E::slots_per_epoch() / 2; let finalized_epoch = 2; let finalized_slot = E::slots_per_epoch() * 2; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let blocks = rig.build_blocks(num_blocks, Payload::Syncing).await; @@ -685,13 +686,13 @@ async fn invalidates_all_descendants() { assert_eq!(fork_parent_state.slot(), fork_parent_slot); let ((fork_block, _), _fork_post_state) = rig.harness.make_block(fork_parent_state, fork_slot).await; - let fork_rpc_block = RpcBlock::new_without_blobs(None, fork_block.clone()); + let fork_lookup_block = LookupBlock::new(fork_block.clone()); let fork_block_root = rig .harness .chain .process_block( - fork_rpc_block.block_root(), - fork_rpc_block, + fork_lookup_block.block_root(), + fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -763,12 +764,14 @@ async fn invalidates_all_descendants() { /// Check that the head will switch after the canonical branch is invalidated. #[tokio::test] async fn switches_heads() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 4 + E::slots_per_epoch() / 2; let finalized_epoch = 2; let finalized_slot = E::slots_per_epoch() * 2; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let blocks = rig.build_blocks(num_blocks, Payload::Syncing).await; @@ -787,13 +790,13 @@ async fn switches_heads() { let ((fork_block, _), _fork_post_state) = rig.harness.make_block(fork_parent_state, fork_slot).await; let fork_parent_root = fork_block.parent_root(); - let fork_rpc_block = RpcBlock::new_without_blobs(None, fork_block.clone()); + let fork_lookup_block = LookupBlock::new(fork_block.clone()); let fork_block_root = rig .harness .chain .process_block( - fork_rpc_block.block_root(), - fork_rpc_block, + fork_lookup_block.block_root(), + fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -860,8 +863,10 @@ async fn switches_heads() { #[tokio::test] async fn invalid_during_processing() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); let roots = &[ rig.import_block(Payload::Valid).await, @@ -892,8 +897,10 @@ async fn invalid_during_processing() { #[tokio::test] async fn invalid_after_optimistic_sync() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let mut roots = vec![ @@ -930,8 +937,10 @@ async fn invalid_after_optimistic_sync() { #[tokio::test] async fn manually_validate_child() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let parent = rig.import_block(Payload::Syncing).await; @@ -948,8 +957,10 @@ async fn manually_validate_child() { #[tokio::test] async fn manually_validate_parent() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let parent = rig.import_block(Payload::Syncing).await; @@ -966,8 +977,10 @@ async fn manually_validate_parent() { #[tokio::test] async fn payload_preparation() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; let el = rig.execution_layer(); @@ -1021,14 +1034,17 @@ async fn payload_preparation() { fee_recipient, None, None, + None, ); assert_eq!(rig.previous_payload_attributes(), payload_attributes); } #[tokio::test] async fn invalid_parent() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. // Import a syncing block atop the transition block (we'll call this the "parent block" since we @@ -1059,9 +1075,9 @@ async fn invalid_parent() { )); // Ensure the block built atop an invalid payload is invalid for import. - let rpc_block = RpcBlock::new_without_blobs(None, block.clone()); + let lookup_block = LookupBlock::new(block.clone()); assert!(matches!( - rig.harness.chain.process_block(rpc_block.block_root(), rpc_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, + rig.harness.chain.process_block(lookup_block.block_root(), lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), ).await, Err(BlockError::ParentExecutionPayloadInvalid { parent_root: invalid_root }) @@ -1077,6 +1093,7 @@ async fn invalid_parent() { Duration::from_secs(0), &state, PayloadVerificationStatus::Optimistic, + block.message().proposer_index(), &rig.harness.chain.spec, ), Err(ForkChoiceError::ProtoArrayStringError(message)) @@ -1090,83 +1107,12 @@ async fn invalid_parent() { )); } -/// Tests to ensure that we will still send a proposer preparation -#[tokio::test] -async fn payload_preparation_before_transition_block() { - let rig = InvalidPayloadRig::new(); - let el = rig.execution_layer(); - - // Run the watchdog routine so that the status of the execution engine is set. This ensures - // that we don't end up with `eth_syncing` requests later in this function that will impede - // testing. - el.watchdog_task().await; - - let head = rig.harness.chain.head_snapshot(); - assert_eq!( - head.beacon_block - .message() - .body() - .execution_payload() - .unwrap() - .block_hash(), - ExecutionBlockHash::zero(), - "the head block is post-bellatrix but pre-transition" - ); - - let current_slot = rig.harness.chain.slot().unwrap(); - let next_slot = current_slot + 1; - let proposer = head - .beacon_state - .get_beacon_proposer_index(next_slot, &rig.harness.chain.spec) - .unwrap(); - let fee_recipient = Address::repeat_byte(99); - - // Provide preparation data to the EL for `proposer`. - el.update_proposer_preparation( - Epoch::new(0), - [( - &ProposerPreparationData { - validator_index: proposer as u64, - fee_recipient, - }, - &None, - )], - ) - .await; - - rig.move_to_terminal_block(); - - rig.harness - .chain - .prepare_beacon_proposer(current_slot) - .await - .unwrap(); - let forkchoice_update_params = rig - .harness - .chain - .canonical_head - .fork_choice_read_lock() - .get_forkchoice_update_parameters(); - rig.harness - .chain - .update_execution_engine_forkchoice( - current_slot, - forkchoice_update_params, - OverrideForkchoiceUpdate::Yes, - ) - .await - .unwrap(); - - let (fork_choice_state, payload_attributes) = rig.previous_forkchoice_update_params(); - let latest_block_hash = rig.latest_execution_block_hash(); - assert_eq!(payload_attributes.suggested_fee_recipient(), fee_recipient); - assert_eq!(fork_choice_state.head_block_hash, latest_block_hash); -} - #[tokio::test] async fn attesting_to_optimistic_head() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let root = rig.import_block(Payload::Syncing).await; @@ -1289,7 +1235,6 @@ impl InvalidHeadSetup { async fn new() -> InvalidHeadSetup { let slots_per_epoch = E::slots_per_epoch(); let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. // Import blocks until the first time the chain finalizes. This avoids @@ -1377,6 +1322,9 @@ impl InvalidHeadSetup { #[tokio::test] async fn recover_from_invalid_head_by_importing_blocks() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let InvalidHeadSetup { rig, fork_block, @@ -1384,12 +1332,12 @@ async fn recover_from_invalid_head_by_importing_blocks() { } = InvalidHeadSetup::new().await; // Import the fork block, it should become the head. - let fork_rpc_block = RpcBlock::new_without_blobs(None, fork_block.clone()); + let fork_lookup_block = LookupBlock::new(fork_block.clone()); rig.harness .chain .process_block( - fork_rpc_block.block_root(), - fork_rpc_block, + fork_lookup_block.block_root(), + fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -1404,7 +1352,7 @@ async fn recover_from_invalid_head_by_importing_blocks() { "the fork block should become the head" ); - let manual_get_head = rig + let (manual_get_head, _) = rig .harness .chain .canonical_head @@ -1416,6 +1364,9 @@ async fn recover_from_invalid_head_by_importing_blocks() { #[tokio::test] async fn recover_from_invalid_head_after_persist_and_reboot() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let InvalidHeadSetup { rig, fork_block: _, @@ -1458,8 +1409,10 @@ async fn recover_from_invalid_head_after_persist_and_reboot() { #[tokio::test] async fn weights_after_resetting_optimistic_status() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let mut roots = vec![]; @@ -1477,7 +1430,7 @@ async fn weights_after_resetting_optimistic_status() { .fork_choice_read_lock() .proto_array() .iter_nodes(&head.head_block_root()) - .map(|node| (node.root, node.weight)) + .map(|node| (node.root(), node.weight())) .collect::<HashMap<_, _>>(); rig.invalidate_manually(roots[1]).await; @@ -1487,7 +1440,7 @@ async fn weights_after_resetting_optimistic_status() { .canonical_head .fork_choice_write_lock() .proto_array_mut() - .set_all_blocks_to_optimistic::<E>(&rig.harness.chain.spec) + .set_all_blocks_to_optimistic::<E>() .unwrap(); let new_weights = rig @@ -1497,7 +1450,7 @@ async fn weights_after_resetting_optimistic_status() { .fork_choice_read_lock() .proto_array() .iter_nodes(&head.head_block_root()) - .map(|node| (node.root, node.weight)) + .map(|node| (node.root(), node.weight())) .collect::<HashMap<_, _>>(); assert_eq!(original_weights, new_weights); diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs new file mode 100644 index 0000000000..47dd1ef517 --- /dev/null +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -0,0 +1,689 @@ +#![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] + +use beacon_chain::test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, DiskHarnessType, test_spec, +}; +use beacon_chain::{ChainConfig, custody_context::NodeCustodyType}; +use bls::Keypair; +use eth2::types::ProposerPreparationData; +use fork_choice::PayloadStatus; +use logging::create_test_tracing_subscriber; +use ssz_types::VariableList; +use state_processing::{ + per_block_processing::{apply_parent_execution_payload, withdrawals::get_expected_withdrawals}, + state_advance::complete_state_advance, +}; +use std::sync::{Arc, LazyLock}; +use store::database::interface::BeaconNodeBackend; +use store::{HotColdDB, StoreConfig}; +use tempfile::{TempDir, tempdir}; +use types::*; + +// Should ideally be divisible by 3. +pub const LOW_VALIDATOR_COUNT: usize = 32; +pub const HIGH_VALIDATOR_COUNT: usize = 64; + +/// A cached set of keys. +static KEYPAIRS: LazyLock<Vec<Keypair>> = + LazyLock::new(|| types::test_utils::generate_deterministic_keypairs(HIGH_VALIDATOR_COUNT)); + +type E = MinimalEthSpec; +type TestHarness = BeaconChainHarness<DiskHarnessType<E>>; + +fn get_store( + db_path: &TempDir, + spec: Arc<ChainSpec>, +) -> Arc<HotColdDB<E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>> { + let store_config = StoreConfig { + prune_payloads: false, + ..StoreConfig::default() + }; + get_store_generic(db_path, store_config, spec) +} + +fn get_store_generic( + db_path: &TempDir, + config: StoreConfig, + spec: Arc<ChainSpec>, +) -> Arc<HotColdDB<E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>> { + create_test_tracing_subscriber(); + let hot_path = db_path.path().join("chain_db"); + let cold_path = db_path.path().join("freezer_db"); + let blobs_path = db_path.path().join("blobs_db"); + + HotColdDB::open( + &hot_path, + &cold_path, + &blobs_path, + |_, _, _| Ok(()), + config, + spec, + ) + .expect("disk store should initialize") +} + +fn get_harness( + store: Arc<HotColdDB<E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>>, + validator_count: usize, +) -> TestHarness { + // Most tests expect to retain historic states, so we use this as the default. + let chain_config = ChainConfig { + archive: true, + ..ChainConfig::default() + }; + get_harness_generic( + store, + validator_count, + chain_config, + NodeCustodyType::Fullnode, + ) +} + +fn get_harness_generic( + store: Arc<HotColdDB<E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>>, + validator_count: usize, + chain_config: ChainConfig, + node_custody_type: NodeCustodyType, +) -> TestHarness { + let harness = TestHarness::builder(MinimalEthSpec) + .spec(store.get_chain_spec().clone()) + .keypairs(KEYPAIRS[0..validator_count].to_vec()) + .fresh_disk_store(store) + .mock_execution_layer() + .chain_config(chain_config) + .node_custody_type(node_custody_type) + .build(); + harness.advance_slot(); + harness +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_next_slot() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(3 * E::slots_per_epoch() + 2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(4 * E::slots_per_epoch()), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_uneven_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(5 * E::slots_per_epoch() - 1), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_empty_parent_next_slot() { + prepare_payload_generic( + PayloadStatus::Empty, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(3 * E::slots_per_epoch() + 2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_empty_parent_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Empty, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(4 * E::slots_per_epoch()), + ) + .await; +} + +async fn prepare_payload_generic( + parent_payload_status: PayloadStatus, + parent_block_slot: Slot, + prepare_slot: Slot, +) { + assert!(parent_block_slot > 0); + + // Post-Gloas test. + let spec = Arc::new(test_spec::<E>()); + if !spec.fork_name_at_slot::<E>(Slot::new(0)).gloas_enabled() { + return; + } + + let num_blocks_produced = parent_block_slot.as_u64() - 1; + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + harness + .extend_chain( + num_blocks_produced as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Advance the slot so the next extend_chain produces at a fresh slot. + harness.advance_slot(); + + // Produce a block with a payload that affects withdrawals for the next slot. + // A switch-to-compounding consolidation changes withdrawal credentials from 0x01 to 0x02, + // which queues the validator's excess balance as a pending deposit and removes it from the + // partial withdrawal sweep. We target an odd-indexed validator since odd validators are + // created with eth1 withdrawal credentials in the interop genesis builder. + let consolidation_request = harness.make_switch_to_compounding_request(1); + + let execution_requests = ExecutionRequests::<E> { + deposits: VariableList::empty(), + withdrawals: VariableList::empty(), + consolidations: VariableList::new(vec![consolidation_request]).unwrap(), + }; + + // Inject the execution requests into the mock EL so the next payload includes them. + harness + .execution_block_generator() + .set_next_execution_requests(execution_requests); + + // Produce and import one more block. Its envelope will contain the consolidation request. + // TODO(gloas): all this ugly plumbing could be avoided with some more "implicit" context + // methods + let state = harness.get_current_state(); + let (block_contents, opt_envelope, parent_block_state) = harness + .make_block_with_envelope(state, parent_block_slot) + .await; + let envelope = opt_envelope.unwrap(); + let block_root = harness + .process_block( + parent_block_slot, + block_contents.0.canonical_root(), + block_contents.clone(), + ) + .await + .unwrap(); + + // TODO(gloas): try a case where head is empty even though envelope is processed + if parent_payload_status == PayloadStatus::Full { + harness + .process_envelope( + block_root.into(), + envelope.clone(), + &parent_block_state, + block_contents.0.state_root(), + ) + .await; + } + + // Verify that the withdrawals computed from the block's state differ from the withdrawals + // computed from the block's state with its payload applied by + // `apply_parent_execution_payload`. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_empty_state = &cached_head.snapshot.beacon_state; + + let mut advanced_empty_state = unadvanced_empty_state.clone(); + complete_state_advance(&mut advanced_empty_state, None, prepare_slot, &spec).unwrap(); + + let mut unadvanced_full_state = unadvanced_empty_state.clone(); + apply_parent_execution_payload( + &mut unadvanced_full_state, + &envelope.message.execution_requests, + &spec, + ) + .unwrap(); + + let mut advanced_full_state = advanced_empty_state.clone(); + apply_parent_execution_payload( + &mut advanced_full_state, + &envelope.message.execution_requests, + &spec, + ) + .unwrap(); + + let withdrawals_unadvanced_empty: Withdrawals<E> = + get_expected_withdrawals(unadvanced_empty_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced_empty: Withdrawals<E> = + get_expected_withdrawals(&advanced_empty_state, &spec) + .unwrap() + .into(); + let withdrawals_unadvanced_full: Withdrawals<E> = + get_expected_withdrawals(&unadvanced_full_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced_full: Withdrawals<E> = + get_expected_withdrawals(&advanced_full_state, &spec) + .unwrap() + .into(); + + assert_ne!( + withdrawals_advanced_empty, withdrawals_advanced_full, + "Applying execution requests should change the expected withdrawals" + ); + + let expect_state_advance_to_change_withdrawals = + prepare_slot.epoch(E::slots_per_epoch()) > parent_block_slot.epoch(E::slots_per_epoch()); + if expect_state_advance_to_change_withdrawals { + if parent_payload_status == fork_choice::PayloadStatus::Full { + assert_ne!( + withdrawals_unadvanced_full, withdrawals_advanced_full, + "Advancing the state should change the withdrawals" + ); + } else { + assert_ne!( + withdrawals_unadvanced_empty, withdrawals_advanced_empty, + "Advancing the state should change the withdrawals" + ); + } + } + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the applied execution_requests). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_empty_state + .get_beacon_proposer_index(prepare_slot, &spec) + .expect("should get proposer index"); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .expect("prepare_beacon_proposer should succeed"); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .expect("should have cached payload attributes for prepare_slot"); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec<Withdrawal> = if parent_payload_status == PayloadStatus::Full { + withdrawals_advanced_full.to_vec() + } else { + withdrawals_advanced_empty.to_vec() + }; + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} state" + ); +} + +#[tokio::test] +async fn prepare_payload_on_genesis_next_slot() { + prepare_payload_on_genesis_generic(Slot::new(1)).await; +} + +#[tokio::test] +async fn prepare_payload_on_genesis_skip_two_epochs() { + prepare_payload_on_genesis_generic(Slot::new(2 * E::slots_per_epoch())).await; +} + +async fn prepare_payload_on_genesis_generic(prepare_slot: Slot) { + // Post-Gloas test. + let spec = Arc::new(test_spec::<E>()); + if !spec.fork_name_at_slot::<E>(Slot::new(0)).gloas_enabled() { + return; + } + + // Genesis is always considered Empty. + let parent_payload_status = PayloadStatus::Empty; + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // At genesis withdrawals are empty (because nothing has happened yet), so we don't assert + // anything about the advanced vs unadvanced state. This test just exists to test that + // calculating payload attributes at genesis works and doesn't error. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_state = &cached_head.snapshot.beacon_state; + + let mut advanced_state = unadvanced_state.clone(); + complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); + + let withdrawals_advanced: Withdrawals<E> = get_expected_withdrawals(&advanced_state, &spec) + .unwrap() + .into(); + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the state advance). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_state + .get_beacon_proposer_index(prepare_slot, &spec) + .unwrap(); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .unwrap(); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec<Withdrawal> = withdrawals_advanced.to_vec(); + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} advanced genesis state" + ); + assert!(actual_withdrawals.is_empty()); +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_no_skip() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 1, + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_one_prior() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 2, + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_one_after() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 1, + Slot::new(2 * E::slots_per_epoch()) + 1, + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_whole_epoch() { + prepare_payload_on_fork_boundary( + Slot::new(E::slots_per_epoch()), + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +async fn prepare_payload_on_fork_boundary( + parent_block_slot: Slot, + prepare_slot: Slot, + gloas_fork_epoch: Epoch, +) { + // Post-Gloas test. + let mut spec = test_spec::<E>(); + if !spec.fork_name_at_slot::<E>(Slot::new(0)).gloas_enabled() { + return; + } + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + let spec = Arc::new(spec); + + // Pre-Gloas blocks are always considered Empty. + let parent_payload_status = PayloadStatus::Empty; + + let num_blocks_produced = parent_block_slot.as_u64(); + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + harness + .extend_chain( + num_blocks_produced as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Verify that the withdrawals computed from the block's state differ from the withdrawals + // computed from the block's state with its payload applied by + // `apply_parent_execution_payload`. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_state = &cached_head.snapshot.beacon_state; + + let mut advanced_state = unadvanced_state.clone(); + complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); + + let withdrawals_unadvanced: Withdrawals<E> = get_expected_withdrawals(unadvanced_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced: Withdrawals<E> = get_expected_withdrawals(&advanced_state, &spec) + .unwrap() + .into(); + + let expect_state_advance_to_change_withdrawals = prepare_slot.epoch(E::slots_per_epoch()) > 0; + if expect_state_advance_to_change_withdrawals { + assert_ne!( + withdrawals_unadvanced, withdrawals_advanced, + "Advancing the state should change the withdrawals" + ); + } + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the applied execution_requests). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_state + .get_beacon_proposer_index(prepare_slot, &spec) + .unwrap(); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .unwrap(); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec<Withdrawal> = withdrawals_advanced.to_vec(); + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + advanced state" + ); +} + +#[tokio::test] +async fn gloas_block_production_caches_blobs_for_column_publishing() { + use beacon_chain::ProduceBlockVerification; + use beacon_chain::graffiti_calculator::GraffitiSettings; + use eth2::types::GraffitiPolicy; + + let spec = Arc::new(test_spec::<E>()); + if !spec.fork_name_at_slot::<E>(Slot::new(0)).gloas_enabled() { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // Configure the mock EL to produce at least 1 blob per block. + harness.execution_block_generator().set_min_blob_count(1); + + // Extend the chain a few slots to get past genesis. + harness + .extend_chain( + (E::slots_per_epoch() as usize) + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + harness.advance_slot(); + let slot = harness.get_current_slot(); + + // Produce a Gloas block directly via produce_block_on_state_gloas so we can + // inspect the pending cache before it's consumed. + let mut state = harness.get_current_state(); + complete_state_advance(&mut state, None, slot, &spec).unwrap(); + state.build_caches(&spec).unwrap(); + + let proposer_index = state.get_beacon_proposer_index(slot, &spec).unwrap(); + let randao_reveal = harness.sign_randao_reveal(&state, proposer_index, slot); + + let (parent_payload_status, parent_envelope) = { + let head = harness.chain.canonical_head.cached_head(); + ( + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), + ) + }; + + let graffiti_settings = GraffitiSettings::new( + Some(Graffiti::default()), + Some(GraffitiPolicy::PreserveUserGraffiti), + ); + + let (_block, _post_state, _value) = harness + .chain + .produce_block_on_state_gloas( + state, + None, + parent_payload_status, + parent_envelope, + slot, + randao_reveal, + graffiti_settings, + ProduceBlockVerification::VerifyRandao, + None, + ) + .await + .unwrap(); + + // The envelope + blobs should now be in the pending cache. + assert!( + harness + .chain + .pending_payload_envelopes + .read() + .contains(slot), + "Pending cache should contain an envelope for the produced slot" + ); + + // Take the blobs from the cache — this is what publish_execution_payload_envelope does. + let blobs = harness + .chain + .pending_payload_envelopes + .write() + .take_blobs(slot); + + assert!( + blobs.is_some(), + "Blobs should be cached alongside the envelope" + ); + + let blobs = blobs.unwrap(); + assert!( + !blobs.is_empty(), + "Blobs should be non-empty when min_blob_count >= 1" + ); + + // Verify take_blobs is consume-once. + let second_take = harness + .chain + .pending_payload_envelopes + .write() + .take_blobs(slot); + assert!( + second_take.is_none(), + "Blobs should only be consumable once" + ); + + // The envelope should still be in the cache after taking blobs. + assert!( + harness + .chain + .pending_payload_envelopes + .read() + .get(slot) + .is_some(), + "Envelope should remain in cache after taking blobs" + ); +} diff --git a/beacon_node/beacon_chain/tests/rewards.rs b/beacon_node/beacon_chain/tests/rewards.rs index ee9cf511ea..bc7c98041f 100644 --- a/beacon_node/beacon_chain/tests/rewards.rs +++ b/beacon_node/beacon_chain/tests/rewards.rs @@ -29,7 +29,7 @@ static KEYPAIRS: LazyLock<Vec<Keypair>> = fn get_harness(spec: ChainSpec) -> BeaconChainHarness<EphemeralHarnessType<E>> { let chain_config = ChainConfig { - reconstruct_historic_states: true, + archive: true, ..Default::default() }; @@ -48,7 +48,7 @@ fn get_harness(spec: ChainSpec) -> BeaconChainHarness<EphemeralHarnessType<E>> { fn get_electra_harness(spec: ChainSpec) -> BeaconChainHarness<EphemeralHarnessType<E>> { let chain_config = ChainConfig { - reconstruct_historic_states: true, + archive: true, ..Default::default() }; @@ -269,16 +269,16 @@ async fn test_rewards_electra_slashings() { harness.add_attester_slashing(vec![0]).unwrap(); let slashed_balance_1 = initial_balances.get_mut(0).unwrap(); let validator_1_effective_balance = state.get_effective_balance(0).unwrap(); - let delta_1 = validator_1_effective_balance - / harness.spec.min_slashing_penalty_quotient_for_state(&state); + let delta_1 = + validator_1_effective_balance / state.get_min_slashing_penalty_quotient(&harness.spec); *slashed_balance_1 -= delta_1; // add a proposer slashing and calculating slashing penalties harness.add_proposer_slashing(1).unwrap(); let slashed_balance_2 = initial_balances.get_mut(1).unwrap(); let validator_2_effective_balance = state.get_effective_balance(1).unwrap(); - let delta_2 = validator_2_effective_balance - / harness.spec.min_slashing_penalty_quotient_for_state(&state); + let delta_2 = + validator_2_effective_balance / state.get_min_slashing_penalty_quotient(&harness.spec); *slashed_balance_2 -= delta_2; check_all_electra_rewards(&harness, initial_balances).await; diff --git a/beacon_node/beacon_chain/tests/schema_stability.rs b/beacon_node/beacon_chain/tests/schema_stability.rs index db7f7dbdbb..8200748ae6 100644 --- a/beacon_node/beacon_chain/tests/schema_stability.rs +++ b/beacon_node/beacon_chain/tests/schema_stability.rs @@ -70,7 +70,7 @@ async fn schema_stability() { let store = get_store(&datadir, store_config, spec.clone()); let chain_config = ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }; @@ -106,8 +106,8 @@ fn check_db_columns() { let current_columns: Vec<&'static str> = DBColumn::iter().map(|c| c.as_str()).collect(); let expected_columns = vec![ "bma", "blk", "blb", "bdc", "bdi", "ste", "hsd", "hsn", "bsn", "bsd", "bss", "bs3", "bcs", - "bst", "exp", "bch", "opo", "etc", "frk", "pkc", "brp", "bsx", "bsr", "bbx", "bbr", "bhr", - "brm", "dht", "cus", "otb", "bhs", "olc", "lcu", "scb", "scm", "dmy", + "bst", "exp", "pay", "bch", "opo", "etc", "frk", "pkc", "brp", "bsx", "bsr", "bbx", "bbr", + "bhr", "brm", "dht", "cus", "otb", "bhs", "olc", "lcu", "scb", "scm", "dmy", ]; assert_eq!(expected_columns, current_columns); } diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index ba0621ae72..86adf50995 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1,7 +1,8 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::Error as AttnError; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::builder::BeaconChainBuilder; use beacon_chain::custody_context::CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS; use beacon_chain::data_availability_checker::AvailableBlock; @@ -21,12 +22,12 @@ use beacon_chain::{ compute_proposer_duties_from_head, ensure_state_can_determine_proposers_for_epoch, }, custody_context::NodeCustodyType, - data_availability_checker::MaybeAvailableBlock, historical_blocks::HistoricalBlockError, migrate::MigratorConfig, }; use bls::{Keypair, Signature, SignatureBytes}; use fixed_bytes::FixedBytesExtended; +use fork_choice::PayloadStatus; use logging::create_test_tracing_subscriber; use maplit::hashset; use rand::Rng; @@ -53,7 +54,7 @@ use types::test_utils::{SeedableRng, XorShiftRng}; use types::*; // Should ideally be divisible by 3. -pub const LOW_VALIDATOR_COUNT: usize = 24; +pub const LOW_VALIDATOR_COUNT: usize = 32; pub const HIGH_VALIDATOR_COUNT: usize = 64; // When set to true, cache any states fetched from the db. @@ -101,7 +102,7 @@ fn get_harness( ) -> TestHarness { // Most tests expect to retain historic states, so we use this as the default. let chain_config = ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }; get_harness_generic( @@ -118,7 +119,8 @@ fn get_harness_import_all_data_columns( ) -> TestHarness { // Most tests expect to retain historic states, so we use this as the default. let chain_config = ChainConfig { - reconstruct_historic_states: true, + ignore_ws_check: true, + archive: true, ..ChainConfig::default() }; get_harness_generic( @@ -147,6 +149,22 @@ fn get_harness_generic( harness } +/// Check that all database invariants hold. +/// +/// Panics with a descriptive message if any invariant is violated. +fn check_db_invariants(harness: &TestHarness) { + let result = harness + .chain + .check_database_invariants() + .expect("invariant check should not error"); + + assert!( + result.is_ok(), + "database invariant violations found:\n{:#?}", + result.violations, + ); +} + fn get_states_descendant_of_block( store: &HotColdDB<E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>, block_root: Hash256, @@ -167,6 +185,10 @@ async fn light_client_bootstrap_test() { // No-op prior to Altair. return; }; + // TODO(EIP-7732): Light client not yet implemented for Gloas. + if spec.is_gloas_scheduled() { + return; + } let db_path = tempdir().unwrap(); let store = get_store_generic(&db_path, StoreConfig::default(), spec.clone()); @@ -222,6 +244,10 @@ async fn light_client_updates_test() { // No-op prior to Altair. return; }; + // TODO(EIP-7732): Light client not yet implemented for Gloas. + if spec.is_gloas_scheduled() { + return; + } let num_final_blocks = E::slots_per_epoch() * 2; let db_path = tempdir().unwrap(); @@ -307,6 +333,7 @@ async fn full_participation_no_skips() { check_split_slot(&harness, store); check_chain_dump(&harness, num_blocks_produced + 1); check_iterators(&harness); + check_db_invariants(&harness); } #[tokio::test] @@ -351,6 +378,7 @@ async fn randomised_skips() { check_split_slot(&harness, store.clone()); check_chain_dump(&harness, num_blocks_produced + 1); check_iterators(&harness); + check_db_invariants(&harness); } #[tokio::test] @@ -399,6 +427,7 @@ async fn long_skip() { check_split_slot(&harness, store); check_chain_dump(&harness, initial_blocks + final_blocks + 1); check_iterators(&harness); + check_db_invariants(&harness); } /// Go forward to the point where the genesis randao value is no longer part of the vector. @@ -548,13 +577,12 @@ async fn epoch_boundary_state_attestation_processing() { .get_blinded_block(&block_root) .unwrap() .expect("block exists"); - // Use get_state as the state may be finalized by this point + // Use get_state as the state may be finalized by this point. + let state_root = block.state_root(); let mut epoch_boundary_state = store - .get_state(&block.state_root(), None, CACHE_STATE_IN_TESTS) + .get_state(&state_root, None, CACHE_STATE_IN_TESTS) .expect("no error") - .unwrap_or_else(|| { - panic!("epoch boundary state should exist {:?}", block.state_root()) - }); + .unwrap_or_else(|| panic!("epoch boundary state should exist {:?}", state_root)); let ebs_state_root = epoch_boundary_state.update_tree_hash_cache().unwrap(); let mut ebs_of_ebs = store .get_state(&ebs_state_root, None, CACHE_STATE_IN_TESTS) @@ -653,8 +681,11 @@ async fn forwards_iter_block_and_state_roots_until() { let block_root = block_roots[slot.as_usize()]; assert_eq!(block_root_iter.next().unwrap().unwrap(), (block_root, slot)); + let (iter_state_root, iter_slot) = state_root_iter.next().unwrap().unwrap(); + assert_eq!(iter_slot, slot); + let state_root = state_roots[slot.as_usize()]; - assert_eq!(state_root_iter.next().unwrap().unwrap(), (state_root, slot)); + assert_eq!(iter_state_root, state_root); } }; @@ -1364,6 +1395,7 @@ async fn proposer_shuffling_root_consistency_at_fork_boundary() { } #[tokio::test] +#[allow(clippy::large_stack_frames)] async fn proposer_shuffling_changing_with_lookahead() { let initial_blocks = E::slots_per_epoch() * 4 - 1; @@ -1767,6 +1799,8 @@ async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { } assert!(!rig.knows_head(&stray_head)); + + check_db_invariants(&rig); } #[tokio::test] @@ -1895,6 +1929,8 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { assert!(!rig.knows_head(&stray_head)); let chain_dump = rig.chain.chain_dump().unwrap(); assert!(get_blocks(&chain_dump).contains(&shared_head)); + + check_db_invariants(&rig); } #[tokio::test] @@ -1986,6 +2022,8 @@ async fn pruning_does_not_touch_blocks_prior_to_finalization() { } rig.assert_knows_head(stray_head.into()); + + check_db_invariants(&rig); } #[tokio::test] @@ -2125,6 +2163,8 @@ async fn prunes_fork_growing_past_youngest_finalized_checkpoint() { } assert!(!rig.knows_head(&stray_head)); + + check_db_invariants(&rig); } // This is to check if state outside of normal block processing are pruned correctly. @@ -2375,6 +2415,8 @@ async fn finalizes_non_epoch_start_slot() { state_hash ); } + + check_db_invariants(&rig); } fn check_all_blocks_exist<'a>( @@ -2641,6 +2683,8 @@ async fn pruning_test( check_all_states_exist(&harness, all_canonical_states.iter()); check_no_states_exist(&harness, stray_states.difference(&all_canonical_states)); check_no_blocks_exist(&harness, stray_blocks.values()); + + check_db_invariants(&harness); } #[tokio::test] @@ -2705,6 +2749,8 @@ async fn garbage_collect_temp_states_from_failed_block_on_finalization() { vec![(genesis_state_root, Slot::new(0))], "get_states_descendant_of_block({bad_block_parent_root:?})" ); + + check_db_invariants(&harness); } #[tokio::test] @@ -2831,12 +2877,6 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { .block_root_at_slot(checkpoint_slot, WhenSlotSkipped::Prev) .unwrap() .unwrap(); - let wss_state_root = harness - .chain - .state_root_at_slot(checkpoint_slot) - .unwrap() - .unwrap(); - let wss_block = harness .chain .store @@ -2844,8 +2884,21 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { .unwrap() .unwrap(); - // The test premise requires the anchor block to have a payload. - assert!(wss_block.message().execution_payload().is_ok()); + let wss_state_root = harness + .chain + .state_root_at_slot(checkpoint_slot) + .unwrap() + .unwrap(); + + // The test premise requires the anchor block to have a payload (or a payload bid in Gloas). + assert!( + wss_block.message().execution_payload().is_ok() + || wss_block + .message() + .body() + .signed_execution_payload_bid() + .is_ok() + ); let wss_blobs_opt = harness .chain @@ -2870,12 +2923,12 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(harness.chain.genesis_time), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); slot_clock.set_slot(harness.get_current_slot().as_u64()); let chain_config = ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }; @@ -2927,15 +2980,19 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { chain.head_snapshot().beacon_state.slot() ); - let payload_exists = chain - .store - .execution_payload_exists(&wss_block_root) - .unwrap_or(false); + // In Gloas, the execution payload envelope is separate from the block and will be synced + // from the network. We don't check for its existence here. + if !wss_block.fork_name_unchecked().gloas_enabled() { + let payload_exists = chain + .store + .execution_payload_exists(&wss_block_root) + .unwrap_or(false); - assert!( - payload_exists, - "Split block payload must exist in the new node's store after checkpoint sync" - ); + assert!( + payload_exists, + "Split block payload must exist in the new node's store after checkpoint sync" + ); + } } async fn weak_subjectivity_sync_test( @@ -2973,18 +3030,17 @@ async fn weak_subjectivity_sync_test( .block_root_at_slot(checkpoint_slot, WhenSlotSkipped::Prev) .unwrap() .unwrap(); - let wss_state_root = harness - .chain - .state_root_at_slot(checkpoint_slot) - .unwrap() - .unwrap(); - let wss_block = harness .chain .store .get_full_block(&wss_block_root) .unwrap() .unwrap(); + let wss_state_root = harness + .chain + .state_root_at_slot(checkpoint_slot) + .unwrap() + .unwrap(); let wss_blobs_opt = harness .chain .get_or_reconstruct_blobs(&wss_block_root) @@ -3011,7 +3067,6 @@ async fn weak_subjectivity_sync_test( let temp2 = tempdir().unwrap(); let store = get_store(&temp2); let spec = test_spec::<E>(); - let seconds_per_slot = spec.seconds_per_slot; let kzg = get_kzg(&spec); @@ -3025,14 +3080,14 @@ async fn weak_subjectivity_sync_test( let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(harness.chain.genesis_time), - Duration::from_secs(seconds_per_slot), + spec.get_slot_duration(), ); slot_clock.set_slot(harness.get_current_slot().as_u64()); let chain_config = ChainConfig { - // Set reconstruct_historic_states to true from the start in the genesis case. This makes + // Set archive to true from the start in the genesis case. This makes // some of the later checks more uniform across the genesis/non-genesis cases. - reconstruct_historic_states: checkpoint_slot == 0, + archive: checkpoint_slot == 0, ..ChainConfig::default() }; @@ -3062,6 +3117,20 @@ async fn weak_subjectivity_sync_test( .build() .expect("should build"); + // Store the WSS envelope to simulate it arriving from network sync. + // In production, the envelope would be synced from the network after checkpoint sync. + if let Some(envelope) = harness + .chain + .store + .get_payload_envelope(&wss_block.canonical_root()) + .unwrap_or(None) + { + beacon_chain + .store + .put_payload_envelope(&wss_block.canonical_root(), &envelope) + .unwrap(); + } + let beacon_chain = Arc::new(beacon_chain); let wss_block_root = wss_block.canonical_root(); let store_wss_block = harness @@ -3081,6 +3150,21 @@ async fn weak_subjectivity_sync_test( assert_eq!(store_wss_blobs_opt, wss_blobs_opt); } + // Store the WSS block's envelope in the new chain (required for Gloas forward sync). + // The first forward block needs the checkpoint block's envelope to determine the parent's + // Full state. + if let Some(envelope) = harness + .chain + .store + .get_payload_envelope(&wss_block_root) + .unwrap() + { + beacon_chain + .store + .put_payload_envelope(&wss_block_root, &envelope) + .unwrap(); + } + // Apply blocks forward to reach head. let chain_dump = harness.chain.chain_dump().unwrap(); let new_blocks = chain_dump @@ -3105,13 +3189,31 @@ async fn weak_subjectivity_sync_test( beacon_chain .process_block( full_block_root, - harness.build_rpc_block_from_store_blobs(Some(block_root), Arc::new(full_block)), + harness.build_range_sync_block_from_store_blobs( + Some(block_root), + Arc::new(full_block), + ), NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), ) .await .unwrap(); + + // Store the envelope and apply it to fork choice. + if let Some(envelope) = &snapshot.execution_envelope { + beacon_chain + .store + .put_payload_envelope(&block_root, envelope) + .unwrap(); + // Update fork choice so head selection accounts for Full payload status. + beacon_chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .unwrap(); + } + beacon_chain.recompute_head_at_current_slot().await; // Check that the new block's state can be loaded correctly. @@ -3175,17 +3277,16 @@ async fn weak_subjectivity_sync_test( .expect("should get block") .expect("should get block"); - if let MaybeAvailableBlock::Available(block) = harness + let range_sync_block = harness + .build_range_sync_block_from_store_blobs(Some(block_root), Arc::new(full_block)); + + let fully_available_block = range_sync_block.into_available_block(); + harness .chain .data_availability_checker - .verify_kzg_for_rpc_block( - harness - .build_rpc_block_from_store_blobs(Some(block_root), Arc::new(full_block)), - ) - .expect("should verify kzg") - { - available_blocks.push(block); - } + .verify_kzg_for_available_block(&fully_available_block) + .expect("should verify kzg"); + available_blocks.push(fully_available_block); } // Corrupt the signature on the 1st block to ensure that the backfill processor is checking @@ -3193,15 +3294,16 @@ async fn weak_subjectivity_sync_test( let mut batch_with_invalid_first_block = available_blocks.iter().map(clone_block).collect::<Vec<_>>(); batch_with_invalid_first_block[0] = { - let (block_root, block, data) = clone_block(&available_blocks[0]).deconstruct(); + let (_, block, data) = clone_block(&available_blocks[0]).deconstruct(); let mut corrupt_block = (*block).clone(); *corrupt_block.signature_mut() = Signature::empty(); - AvailableBlock::__new_for_testing( - block_root, + AvailableBlock::new( Arc::new(corrupt_block), data, + &beacon_chain.data_availability_checker, Arc::new(spec), ) + .expect("available block") }; // Importing the invalid batch should error. @@ -3263,6 +3365,17 @@ async fn weak_subjectivity_sync_test( } assert_eq!(beacon_chain.store.get_oldest_block_slot(), 0); + // Store envelopes for all historic blocks (needed for dumping the chain from the new node). + for snapshot in chain_dump.iter() { + let block_root = snapshot.beacon_block_root; + if let Some(envelope) = &snapshot.execution_envelope { + beacon_chain + .store + .put_payload_envelope(&block_root, envelope) + .unwrap(); + } + } + // Sanity check for non-aligned WSS starts, to make sure the WSS block is persisted properly if wss_block_slot != wss_state_slot { let new_node_block_root_at_wss_block = beacon_chain @@ -3332,13 +3445,12 @@ async fn weak_subjectivity_sync_test( assert_eq!(state.canonical_root().unwrap(), state_root); } - // Anchor slot is still set to the slot of the checkpoint block. - // Note: since hot tree states the anchor slot is set to the aligned ws state slot - // https://github.com/sigp/lighthouse/pull/6750 - let wss_aligned_slot = if checkpoint_slot % E::slots_per_epoch() == 0 { - checkpoint_slot + // Anchor slot is set to the WSS state slot, which is always epoch-aligned (the state is + // advanced to an epoch boundary during checkpoint sync). + let wss_aligned_slot = if wss_state_slot % E::slots_per_epoch() == 0 { + wss_state_slot } else { - (checkpoint_slot.epoch(E::slots_per_epoch()) + Epoch::new(1)) + (wss_state_slot.epoch(E::slots_per_epoch()) + Epoch::new(1)) .start_slot(E::slots_per_epoch()) }; assert_eq!(store.get_anchor_info().anchor_slot, wss_aligned_slot); @@ -3356,6 +3468,16 @@ async fn weak_subjectivity_sync_test( store.clone().reconstruct_historic_states(None).unwrap(); assert_eq!(store.get_anchor_info().anchor_slot, wss_aligned_slot); assert_eq!(store.get_anchor_info().state_upper_limit, Slot::new(0)); + + // Check database invariants after full checkpoint sync + backfill + reconstruction. + let result = beacon_chain + .check_database_invariants() + .expect("invariant check should not error"); + assert!( + result.is_ok(), + "database invariant violations:\n{:#?}", + result.violations, + ); } // This test prunes data columns from epoch 0 and then tries to re-import them via @@ -3380,7 +3502,7 @@ async fn test_import_historical_data_columns_batch() { .await; harness.advance_slot(); - let block_root_iter = harness + let block_root_and_slot = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); @@ -3388,9 +3510,14 @@ async fn test_import_historical_data_columns_batch() { let mut data_columns_list = vec![]; // Get all data columns for epoch 0 - for block in block_root_iter { - let (block_root, _) = block.unwrap(); - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + for block_root_and_slot in block_root_and_slot { + let (block_root, slot) = block_root_and_slot.unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); for data_column in data_columns.unwrap_or_default() { data_columns_list.push(data_column); } @@ -3415,15 +3542,20 @@ async fn test_import_historical_data_columns_batch() { .try_prune_blobs(true, Epoch::new(2)) .unwrap(); - let block_root_iter = harness + let block_root_and_slot_iter = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); // Assert that data columns no longer exist for epoch 0 - for block in block_root_iter { - let (block_root, _) = block.unwrap(); - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + for block_root_and_slot in block_root_and_slot_iter { + let (block_root, slot) = block_root_and_slot.unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); assert!(data_columns.is_none()) } @@ -3433,14 +3565,14 @@ async fn test_import_historical_data_columns_batch() { .import_historical_data_column_batch(Epoch::new(0), data_columns_list, cgc) .unwrap(); - let block_root_iter = harness + let block_root_and_slot_iter = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); // Assert that data columns now exist for epoch 0 - for block in block_root_iter { - let (block_root, _) = block.unwrap(); + for block_root_and_slot in block_root_and_slot_iter { + let (block_root, slot) = block_root_and_slot.unwrap(); if !harness .get_block(block_root.into()) .unwrap() @@ -3450,7 +3582,12 @@ async fn test_import_historical_data_columns_batch() { .unwrap() .is_empty() { - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); assert!(data_columns.is_some()) }; } @@ -3478,7 +3615,7 @@ async fn test_import_historical_data_columns_batch_mismatched_block_root() { .await; harness.advance_slot(); - let block_root_iter = harness + let block_root_and_slot_iter = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); @@ -3487,14 +3624,23 @@ async fn test_import_historical_data_columns_batch_mismatched_block_root() { // Get all data columns from start_slot to end_slot // and mutate the data columns with an invalid block root - for block in block_root_iter { - let (block_root, _) = block.unwrap(); - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + for block_root_and_slot in block_root_and_slot_iter { + let (block_root, slot) = block_root_and_slot.unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); for data_column in data_columns.unwrap_or_default() { let mut data_column = (*data_column).clone(); - if data_column.index % 2 == 0 { - data_column.signed_block_header.message.body_root = Hash256::ZERO; + if data_column.index() % 2 == 0 { + data_column + .signed_block_header_mut() + .unwrap() + .message + .body_root = Hash256::ZERO; } data_columns_list.push(Arc::new(data_column)); @@ -3519,15 +3665,20 @@ async fn test_import_historical_data_columns_batch_mismatched_block_root() { .try_prune_blobs(true, Epoch::new(2)) .unwrap(); - let block_root_iter = harness + let block_root_and_slot_iter = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); // Assert there are no columns between start_slot and end_slot - for block in block_root_iter { - let (block_root, _) = block.unwrap(); - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + for block_root_and_slot in block_root_and_slot_iter { + let (block_root, slot) = block_root_and_slot.unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); assert!(data_columns.is_none()) } @@ -3554,6 +3705,10 @@ async fn test_import_historical_data_columns_batch_no_block_found() { if fork_name_from_env().is_some_and(|f| !f.fulu_enabled()) { return; }; + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } let spec = test_spec::<E>(); let db_path = tempdir().unwrap(); @@ -3573,20 +3728,29 @@ async fn test_import_historical_data_columns_batch_no_block_found() { .await; harness.advance_slot(); - let block_root_iter = harness + let block_root_and_slot_iter = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); let mut data_columns_list = vec![]; - for block in block_root_iter { - let (block_root, _) = block.unwrap(); - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + for block_root_and_slot in block_root_and_slot_iter { + let (block_root, slot) = block_root_and_slot.unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); for data_column in data_columns.unwrap_or_default() { let mut data_column = (*data_column).clone(); - data_column.signed_block_header.message.body_root = Hash256::ZERO; + data_column + .signed_block_header_mut() + .unwrap() + .message + .body_root = Hash256::ZERO; data_columns_list.push(Arc::new(data_column)); } } @@ -3609,14 +3773,19 @@ async fn test_import_historical_data_columns_batch_no_block_found() { .try_prune_blobs(true, Epoch::new(2)) .unwrap(); - let block_root_iter = harness + let block_root_and_slot_iter = harness .chain .forwards_iter_block_roots_until(start_slot, end_slot) .unwrap(); - for block in block_root_iter { - let (block_root, _) = block.unwrap(); - let data_columns = harness.chain.store.get_data_columns(&block_root).unwrap(); + for block_root_and_slot in block_root_and_slot_iter { + let (block_root, slot) = block_root_and_slot.unwrap(); + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + let data_columns = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap(); assert!(data_columns.is_none()) } @@ -3638,7 +3807,7 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { let temp = tempdir().unwrap(); let store = get_store(&temp); let chain_config = ChainConfig { - reconstruct_historic_states: false, + archive: false, ..ChainConfig::default() }; let harness = get_harness_generic( @@ -3650,12 +3819,14 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { let all_validators = (0..LOW_VALIDATOR_COUNT).collect::<Vec<_>>(); - let split_slot = Slot::new(E::slots_per_epoch() * 4); + let finalized_epoch_start_slot = Slot::new(E::slots_per_epoch() * 4); let pre_skips = 1; let post_skips = 1; - // Build the chain up to the intended split slot, with 3 skips before the split. - let slots = (1..=split_slot.as_u64() - pre_skips) + let split_slot = finalized_epoch_start_slot; + + // Build the chain up to the intended finalized epoch slot, with 1 skip before the split. + let slots = (1..=finalized_epoch_start_slot.as_u64() - pre_skips) .map(Slot::new) .collect::<Vec<_>>(); @@ -3674,20 +3845,26 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { // // - one that is invalid because it conflicts with finalization (slot <= finalized_slot) // - one that is valid because its slot is not finalized (slot > finalized_slot) + // + // Note: block verification uses finalized_checkpoint.epoch.start_slot() (== + // finalized_epoch_start_slot) for the finalized slot check. let (unadvanced_split_state, unadvanced_split_state_root) = harness.get_current_state_and_root(); let ((invalid_fork_block, _), _) = harness - .make_block(unadvanced_split_state.clone(), split_slot) + .make_block(unadvanced_split_state.clone(), finalized_epoch_start_slot) .await; let ((valid_fork_block, _), _) = harness - .make_block(unadvanced_split_state.clone(), split_slot + 1) + .make_block( + unadvanced_split_state.clone(), + finalized_epoch_start_slot + 1, + ) .await; // Advance the chain so that the intended split slot is finalized. // Do not attest in the epoch boundary slot, to make attestation production later easier (no // equivocations). - let finalizing_slot = split_slot + 2 * E::slots_per_epoch(); + let finalizing_slot = finalized_epoch_start_slot + 2 * E::slots_per_epoch(); for _ in 0..pre_skips + post_skips { harness.advance_slot(); } @@ -3702,13 +3879,13 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { assert_eq!(split.block_root, valid_fork_block.parent_root()); assert_ne!(split.state_root, unadvanced_split_state_root); - let invalid_fork_rpc_block = RpcBlock::new_without_blobs(None, invalid_fork_block.clone()); + let invalid_fork_lookup_block = LookupBlock::new(invalid_fork_block.clone()); // Applying the invalid block should fail. let err = harness .chain .process_block( - invalid_fork_rpc_block.block_root(), - invalid_fork_rpc_block, + invalid_fork_lookup_block.block_root(), + invalid_fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -3718,12 +3895,12 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { assert!(matches!(err, BlockError::WouldRevertFinalizedSlot { .. })); // Applying the valid block should succeed, but it should not become head. - let valid_fork_rpc_block = RpcBlock::new_without_blobs(None, valid_fork_block.clone()); + let valid_fork_lookup_block = LookupBlock::new(valid_fork_block.clone()); harness .chain .process_block( - valid_fork_rpc_block.block_root(), - valid_fork_rpc_block, + valid_fork_lookup_block.block_root(), + valid_fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -3865,204 +4042,18 @@ async fn finalizes_after_resuming_from_db() { ); } -#[allow(clippy::large_stack_frames)] -#[tokio::test] -async fn revert_minority_fork_on_resume() { - let validator_count = 16; - let slots_per_epoch = MinimalEthSpec::slots_per_epoch(); - - let fork_epoch = Epoch::new(4); - let fork_slot = fork_epoch.start_slot(slots_per_epoch); - let initial_blocks = slots_per_epoch * fork_epoch.as_u64() - 1; - let post_fork_blocks = slots_per_epoch * 3; - - let mut spec1 = MinimalEthSpec::default_spec(); - spec1.altair_fork_epoch = None; - let mut spec2 = MinimalEthSpec::default_spec(); - spec2.altair_fork_epoch = Some(fork_epoch); - - let seconds_per_slot = spec1.seconds_per_slot; - - let all_validators = (0..validator_count).collect::<Vec<usize>>(); - - // Chain with no fork epoch configured. - let db_path1 = tempdir().unwrap(); - let store1 = get_store_generic(&db_path1, StoreConfig::default(), spec1.clone()); - let harness1 = BeaconChainHarness::builder(MinimalEthSpec) - .spec(spec1.clone().into()) - .keypairs(KEYPAIRS[0..validator_count].to_vec()) - .fresh_disk_store(store1) - .mock_execution_layer() - .build(); - - // Chain with fork epoch configured. - let db_path2 = tempdir().unwrap(); - let store2 = get_store_generic(&db_path2, StoreConfig::default(), spec2.clone()); - let harness2 = BeaconChainHarness::builder(MinimalEthSpec) - .spec(spec2.clone().into()) - .keypairs(KEYPAIRS[0..validator_count].to_vec()) - .fresh_disk_store(store2) - .mock_execution_layer() - .build(); - - // Apply the same blocks to both chains initially. - let mut state = harness1.get_current_state(); - let mut block_root = harness1.chain.genesis_block_root; - for slot in (1..=initial_blocks).map(Slot::new) { - let state_root = state.update_tree_hash_cache().unwrap(); - - let attestations = harness1.make_attestations( - &all_validators, - &state, - state_root, - block_root.into(), - slot, - ); - harness1.set_current_slot(slot); - harness2.set_current_slot(slot); - harness1.process_attestations(attestations.clone(), &state); - harness2.process_attestations(attestations, &state); - - let ((block, blobs), new_state) = harness1.make_block(state, slot).await; - - harness1 - .process_block(slot, block.canonical_root(), (block.clone(), blobs.clone())) - .await - .unwrap(); - harness2 - .process_block(slot, block.canonical_root(), (block.clone(), blobs.clone())) - .await - .unwrap(); - - state = new_state; - block_root = block.canonical_root(); - } - - assert_eq!(harness1.head_slot(), fork_slot - 1); - assert_eq!(harness2.head_slot(), fork_slot - 1); - - // Fork the two chains. - let mut state1 = state.clone(); - let mut state2 = state.clone(); - - let mut majority_blocks = vec![]; - - for i in 0..post_fork_blocks { - let slot = fork_slot + i; - - // Attestations on majority chain. - let state_root = state.update_tree_hash_cache().unwrap(); - - let attestations = harness2.make_attestations( - &all_validators, - &state2, - state_root, - block_root.into(), - slot, - ); - harness2.set_current_slot(slot); - harness2.process_attestations(attestations, &state2); - - // Minority chain block (no attesters). - let ((block1, blobs1), new_state1) = harness1.make_block(state1, slot).await; - harness1 - .process_block(slot, block1.canonical_root(), (block1, blobs1)) - .await - .unwrap(); - state1 = new_state1; - - // Majority chain block (all attesters). - let ((block2, blobs2), new_state2) = harness2.make_block(state2, slot).await; - harness2 - .process_block(slot, block2.canonical_root(), (block2.clone(), blobs2)) - .await - .unwrap(); - - state2 = new_state2; - block_root = block2.canonical_root(); - - majority_blocks.push(block2); - } - - let end_slot = fork_slot + post_fork_blocks - 1; - assert_eq!(harness1.head_slot(), end_slot); - assert_eq!(harness2.head_slot(), end_slot); - - // Resume from disk with the hard-fork activated: this should revert the post-fork blocks. - // We have to do some hackery with the `slot_clock` so that the correct slot is set when - // the beacon chain builder loads the head block. - drop(harness1); - let resume_store = get_store_generic(&db_path1, StoreConfig::default(), spec2.clone()); - - let resumed_harness = TestHarness::builder(MinimalEthSpec) - .spec(spec2.clone().into()) - .keypairs(KEYPAIRS[0..validator_count].to_vec()) - .resumed_disk_store(resume_store) - .override_store_mutator(Box::new(move |mut builder| { - builder = builder - .resume_from_db() - .unwrap() - .testing_slot_clock(Duration::from_secs(seconds_per_slot)) - .unwrap(); - builder - .get_slot_clock() - .unwrap() - .set_slot(end_slot.as_u64()); - builder - })) - .mock_execution_layer() - .build(); - - // Head should now be just before the fork. - resumed_harness.chain.recompute_head_at_current_slot().await; - assert_eq!(resumed_harness.head_slot(), fork_slot - 1); - - // Fork choice should only know the canonical head. When we reverted the head we also should - // have called `reset_fork_choice_to_finalization` which rebuilds fork choice from scratch - // without the reverted block. - assert_eq!( - resumed_harness.chain.heads(), - vec![(resumed_harness.head_block_root(), fork_slot - 1)] - ); - - // Apply blocks from the majority chain and trigger finalization. - let initial_split_slot = resumed_harness.chain.store.get_split_slot(); - for block in &majority_blocks { - resumed_harness - .process_block_result((block.clone(), None)) - .await - .unwrap(); - - // The canonical head should be the block from the majority chain. - resumed_harness.chain.recompute_head_at_current_slot().await; - assert_eq!(resumed_harness.head_slot(), block.slot()); - assert_eq!(resumed_harness.head_block_root(), block.canonical_root()); - } - let advanced_split_slot = resumed_harness.chain.store.get_split_slot(); - - // Check that the migration ran successfully. - assert!(advanced_split_slot > initial_split_slot); - - // Check that there is only a single head now matching harness2 (the minority chain is gone). - let heads = resumed_harness.chain.heads(); - assert_eq!(heads, harness2.chain.heads()); - assert_eq!(heads.len(), 1); -} - // This test checks whether the schema downgrade from the latest version to some minimum supported // version is correct. This is the easiest schema test to write without historic versions of // Lighthouse on-hand, but has the disadvantage that the min version needs to be adjusted manually // as old downgrades are deprecated. -async fn schema_downgrade_to_min_version( - store_config: StoreConfig, - reconstruct_historic_states: bool, -) { +async fn schema_downgrade_to_min_version(store_config: StoreConfig, archive: bool) { let num_blocks_produced = E::slots_per_epoch() * 4; let db_path = tempdir().unwrap(); let spec = test_spec::<E>(); + let is_gloas = spec.is_gloas_scheduled(); let chain_config = ChainConfig { - reconstruct_historic_states, + archive, ..ChainConfig::default() }; @@ -4082,10 +4073,10 @@ async fn schema_downgrade_to_min_version( ) .await; - let min_version = if spec.is_fulu_scheduled() { - SchemaVersion(27) + let min_version = if is_gloas { + SchemaVersion(29) } else { - SchemaVersion(22) + SchemaVersion(28) }; // Save the slot clock so that the new harness doesn't revert in time. @@ -4117,7 +4108,7 @@ async fn schema_downgrade_to_min_version( .build(); // Check chain dump for appropriate range depending on whether this is an archive node. - let chain_dump_start_slot = if reconstruct_historic_states { + let chain_dump_start_slot = if archive { Slot::new(0) } else { store.get_split_slot() @@ -4656,6 +4647,10 @@ async fn fulu_prune_data_columns_happy_case() { // No-op if PeerDAS not scheduled. return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if store.get_chain_spec().is_gloas_scheduled() { + return; + } let Some(fulu_fork_epoch) = store.get_chain_spec().fulu_fork_epoch else { // No-op prior to Fulu. return; @@ -4711,6 +4706,10 @@ async fn fulu_prune_data_columns_no_finalization() { // No-op if PeerDAS not scheduled. return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if store.get_chain_spec().is_gloas_scheduled() { + return; + } let Some(fulu_fork_epoch) = store.get_chain_spec().fulu_fork_epoch else { // No-op prior to Fulu. return; @@ -4930,6 +4929,10 @@ async fn fulu_prune_data_columns_margin_test(margin: u64) { // No-op if PeerDAS not scheduled. return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if store.get_chain_spec().is_gloas_scheduled() { + return; + } let Some(fulu_fork_epoch) = store.get_chain_spec().fulu_fork_epoch else { // No-op prior to Fulu. return; @@ -4995,7 +4998,13 @@ fn check_data_column_existence( .unwrap() .map(Result::unwrap) { - if let Some(columns) = harness.chain.store.get_data_columns(&block_root).unwrap() { + let fork_name = harness.spec.fork_name_at_slot::<E>(slot); + if let Some(columns) = harness + .chain + .store + .get_data_columns(&block_root, fork_name) + .unwrap() + { assert!(should_exist, "columns at slot {slot} exist but should not"); columns_seen += columns.len(); } else { @@ -5091,7 +5100,7 @@ async fn ancestor_state_root_prior_to_split() { ..StoreConfig::default() }; let chain_config = ChainConfig { - reconstruct_historic_states: false, + archive: false, ..ChainConfig::default() }; @@ -5184,7 +5193,7 @@ async fn replay_from_split_state() { ..StoreConfig::default() }; let chain_config = ChainConfig { - reconstruct_historic_states: false, + archive: false, ..ChainConfig::default() }; @@ -5241,6 +5250,10 @@ async fn test_custody_column_filtering_regular_node() { if !test_spec::<E>().is_peer_das_scheduled() { return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if test_spec::<E>().is_gloas_scheduled() { + return; + } let db_path = tempdir().unwrap(); let store = get_store(&db_path); @@ -5285,6 +5298,10 @@ async fn test_custody_column_filtering_supernode() { if !test_spec::<E>().is_peer_das_scheduled() { return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if test_spec::<E>().is_gloas_scheduled() { + return; + } let db_path = tempdir().unwrap(); let store = get_store(&db_path); @@ -5419,8 +5436,8 @@ async fn test_safely_backfill_data_column_custody_info() { .await; let epoch_before_increase = Epoch::new(start_epochs); - let effective_delay_slots = - CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS / harness.chain.spec.seconds_per_slot; + let effective_delay_slots = CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS + / harness.chain.spec.get_slot_duration().as_secs(); let cgc_change_slot = epoch_before_increase.end_slot(E::slots_per_epoch()); @@ -5511,10 +5528,12 @@ fn assert_chains_pretty_much_the_same<T: BeaconChainTypes>(a: &BeaconChain<T>, b .fork_choice_write_lock() .get_head(slot, &spec) .unwrap() + .0 == b.canonical_head .fork_choice_write_lock() .get_head(slot, &spec) - .unwrap(), + .unwrap() + .0, "fork_choice heads should be equal" ); } @@ -5548,6 +5567,286 @@ fn check_finalization(harness: &TestHarness, expected_slot: u64) { ); } +// ===================== Gloas Store Tests ===================== + +/// Test basic Gloas block + envelope storage and retrieval. +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_no_skips() { + test_gloas_block_and_envelope_storage_generic(32, vec![], false).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_some_skips() { + test_gloas_block_and_envelope_storage_generic(32, vec![2, 4, 5, 16, 23, 24, 25], false).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_no_skips_w_cache() { + test_gloas_block_and_envelope_storage_generic(32, vec![], true).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_some_skips_w_cache() { + test_gloas_block_and_envelope_storage_generic(32, vec![2, 4, 5, 16, 23, 24, 25], true).await +} + +async fn test_gloas_block_and_envelope_storage_generic( + num_slots: u64, + skipped_slots: Vec<u64>, + use_state_cache: bool, +) { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store_config = if !use_state_cache { + StoreConfig { + state_cache_size: new_non_zero_usize(1), + ..StoreConfig::default() + } + } else { + StoreConfig::default() + }; + let spec = test_spec::<E>(); + let store = get_store_generic(&db_path, store_config, spec); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + let spec = &harness.chain.spec; + + let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state; + + let mut block_roots = vec![]; + let mut stored_states = vec![(Slot::new(0), genesis_state_root)]; + + for i in 1..=num_slots { + let slot = Slot::new(i); + harness.advance_slot(); + + if skipped_slots.contains(&i) { + complete_state_advance(&mut state, None, slot, spec) + .expect("should be able to advance state to slot"); + + let state_root = state.canonical_root().unwrap(); + store.put_state(&state_root, &state).unwrap(); + stored_states.push((slot, state_root)); + } + + let (block_contents, envelope, mut post_block_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + // Process the block. + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let state_root = post_block_state.update_tree_hash_cache().unwrap(); + stored_states.push((slot, state_root)); + + // Process the envelope. + let envelope = envelope.expect("Gloas block should have envelope"); + harness + .process_envelope(block_root, envelope, &post_block_state, state_root) + .await; + + block_roots.push(block_root); + state = post_block_state; + } + + // Verify block storage. + for (i, block_root) in block_roots.iter().enumerate() { + // Block can be loaded. + assert!( + store.get_blinded_block(block_root).unwrap().is_some(), + "block at slot {} should be in DB", + i + 1 + ); + + // Envelope can be loaded. + let loaded_envelope = store.get_payload_envelope(block_root).unwrap(); + assert!( + loaded_envelope.is_some(), + "envelope at slot {} should be in DB", + i + 1 + ); + } + + // Verify state storage. + // Iterate in reverse order to frustrate the cache. + for (slot, state_root) in stored_states.into_iter().rev() { + println!("{slot}: {state_root:?}"); + let Some(mut loaded_state) = store + .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) + .unwrap() + else { + panic!("missing state at slot {slot} with root {state_root:?}"); + }; + assert_eq!(loaded_state.slot(), slot); + assert_eq!( + loaded_state.canonical_root().unwrap(), + state_root, + "slot = {slot}" + ); + } + check_db_invariants(&harness); +} + +/// Test that Gloas block replay works without envelopes. +#[tokio::test] +async fn test_gloas_block_replay_with_envelopes() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + let num_blocks = 16u64; + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state.clone(); + + let mut last_block_root = Hash256::zero(); + let mut states = HashMap::new(); + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, mut block_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let state_root = block_state.update_tree_hash_cache().unwrap(); + states.insert(slot, (state_root, block_state.clone())); + + let envelope = envelope.expect("Gloas block should have envelope"); + harness + .process_envelope(block_root, envelope, &block_state, state_root) + .await; + + last_block_root = block_root; + state = block_state; + } + + let end_slot = Slot::new(num_blocks); + + // Load blocks for replay. + let blocks = store + .load_blocks_to_replay(Slot::new(0), end_slot, last_block_root) + .unwrap(); + assert!(!blocks.is_empty(), "should have blocks for replay"); + + // Replay blocks and verify against the expected state. + let mut replayed = BlockReplayer::<MinimalEthSpec>::new(genesis_state, store.get_chain_spec()) + .no_signature_verification() + .minimal_block_root_verification() + .apply_blocks(blocks, None) + .expect("should replay blocks") + .into_state(); + replayed.apply_pending_mutations().unwrap(); + + let (_, mut expected) = states.get(&end_slot).unwrap().clone(); + expected.apply_pending_mutations().unwrap(); + + replayed.drop_all_caches().unwrap(); + expected.drop_all_caches().unwrap(); + assert_eq!( + replayed, expected, + "replayed state should match stored state" + ); + check_db_invariants(&harness); +} + +/// Test the hot state hierarchy with Full states stored as ReplayFrom. +#[tokio::test] +async fn test_gloas_hot_state_hierarchy() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // Build enough blocks to span multiple epochs. With MinimalEthSpec (8 slots/epoch), + // 40 slots covers 5 epochs. + let num_blocks = E::slots_per_epoch() * 5; + let all_validators = (0..LOW_VALIDATOR_COUNT).collect::<Vec<_>>(); + + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + + // Use manual block building with envelopes for the first few blocks, + // then use the standard attested-blocks path once we've verified envelope handling. + let mut state = genesis_state; + let mut last_block_root = Hash256::zero(); + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, mut block_state) = + harness.make_block_with_envelope(state.clone(), slot).await; + let block_root = block_contents.0.canonical_root(); + let signed_block = block_contents.0.clone(); + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + // Attest to the current block at its own slot (same-slot attestation). + // In Gloas, same-slot attestations have index=0 and route to Pending in + // fork choice, correctly propagating weight through the Full path. + let state_root = block_state.update_tree_hash_cache().unwrap(); + harness.attest_block( + &block_state, + state_root, + block_root.into(), + &signed_block, + &all_validators, + ); + + let envelope = envelope.expect("Gloas block should have envelope"); + harness + .process_envelope(block_root, envelope, &block_state, state_root) + .await; + + last_block_root = block_root; + state = block_state; + } + + // Head should be the block at slot 40 with full payload. + let head = harness.chain.canonical_head.cached_head(); + assert_eq!(head.head_block_root(), last_block_root); + assert_eq!(head.head_payload_status(), PayloadStatus::Full); + + // States at all slots on the canonical chain should be retrievable. + for slot_num in 1..=num_blocks { + let slot = Slot::new(slot_num); + // Get the state root from the block at this slot via the state root iterator. + let state_root = harness.chain.state_root_at_slot(slot).unwrap().unwrap(); + + let mut loaded_state = store + .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) + .unwrap() + .unwrap_or_else(|| panic!("missing state at {slot}/{state_root:?}")); + assert_eq!(loaded_state.canonical_root().unwrap(), state_root); + } + + // Verify chain dump and iterators work with Gloas states. + check_chain_dump(&harness, num_blocks + 1); + check_iterators(&harness); + check_db_invariants(&harness); +} + /// Check that the HotColdDB's split_slot is equal to the start slot of the last finalized epoch. fn check_split_slot( harness: &TestHarness, @@ -5599,7 +5898,9 @@ fn check_chain_dump_from_slot(harness: &TestHarness, from_slot: Slot, expected_l ); // Check presence of execution payload on disk. - if harness.chain.spec.bellatrix_fork_epoch.is_some() { + if harness.chain.spec.bellatrix_fork_epoch.is_some() + && !harness.chain.spec.is_gloas_scheduled() + { assert!( harness .chain @@ -5680,6 +5981,226 @@ fn check_iterators_from_slot(harness: &TestHarness, slot: Slot) { ); } +/// Test that blocks with default (pre-merge) execution payloads and non-default (post-merge) +/// execution payloads can be produced, stored, and retrieved correctly through a merge transition. +/// +/// Spec (see .claude/plans/8658.md): +/// - Bellatrix at epoch 0 (genesis), genesis has default execution payload header +/// - Slots 1-9: blocks have default (zeroed) execution payloads +/// - Slot 10: first block with a non-default execution payload (merge transition block) +/// - Slots 11-32+: non-default payloads, each with parent_hash == prev payload block_hash +/// - Chain must finalize past genesis +#[tokio::test] +async fn bellatrix_produce_and_store_payloads() { + use beacon_chain::test_utils::{ + DEFAULT_ETH1_BLOCK_HASH, HARNESS_GENESIS_TIME, InteropGenesisBuilder, + }; + use safe_arith::SafeArith; + use state_processing::per_block_processing::is_merge_transition_complete; + use tree_hash::TreeHash; + + let merge_slot = 10u64; + let total_slots = 48u64; + let spec = ForkName::Bellatrix.make_genesis_spec(E::default_spec()); + + // Build genesis state with a default (zeroed) execution payload header so that + // is_merge_transition_complete = false at genesis. + let keypairs = KEYPAIRS[0..LOW_VALIDATOR_COUNT].to_vec(); + let genesis_state = InteropGenesisBuilder::default() + .set_alternating_eth1_withdrawal_credentials() + .set_opt_execution_payload_header(None) + .build_genesis_state( + &keypairs, + HARNESS_GENESIS_TIME, + Hash256::from_slice(DEFAULT_ETH1_BLOCK_HASH), + &spec, + ) + .unwrap(); + + assert!( + !is_merge_transition_complete(&genesis_state), + "genesis should NOT have merge complete" + ); + + let db_path = tempdir().unwrap(); + let store = get_store_generic( + &db_path, + StoreConfig { + prune_payloads: false, + ..StoreConfig::default() + }, + spec.clone(), + ); + + let chain_config = ChainConfig { + archive: true, + ..ChainConfig::default() + }; + let harness = TestHarness::builder(MinimalEthSpec) + .spec(store.get_chain_spec().clone()) + .keypairs(keypairs.clone()) + .fresh_disk_store(store.clone()) + .override_store_mutator(Box::new(move |builder: BeaconChainBuilder<_>| { + builder + .genesis_state(genesis_state) + .expect("should set genesis state") + })) + .mock_execution_layer() + .chain_config(chain_config) + .build(); + + harness + .mock_execution_layer + .as_ref() + .unwrap() + .server + .all_payloads_valid(); + + harness.advance_slot(); + + // Phase 1: slots 1 to merge_slot-1 — blocks with default execution payloads. + let mut state = harness.get_current_state(); + for slot_num in 1..merge_slot { + let slot = Slot::new(slot_num); + harness.advance_slot(); + harness + .build_and_import_block_with_payload( + &mut state, + slot, + ExecutionPayloadBellatrix::default(), + ) + .await; + state = harness.get_current_state(); + } + + // Phase 2: slot merge_slot — the merge transition block with a real payload. + { + let slot = Slot::new(merge_slot); + harness.advance_slot(); + + // Advance state to compute correct timestamp and randao. + let mut pre_state = state.clone(); + complete_state_advance(&mut pre_state, None, slot, &harness.spec) + .expect("should advance state"); + pre_state + .build_caches(&harness.spec) + .expect("should build caches"); + + let timestamp = pre_state + .genesis_time() + .safe_add( + slot.as_u64() + .safe_mul(harness.spec.get_slot_duration().as_secs()) + .unwrap(), + ) + .unwrap(); + let prev_randao = *pre_state.get_randao_mix(pre_state.current_epoch()).unwrap(); + + let mut transition_payload = ExecutionPayloadBellatrix { + parent_hash: ExecutionBlockHash::zero(), + fee_recipient: Address::repeat_byte(42), + receipts_root: Hash256::repeat_byte(42), + state_root: Hash256::repeat_byte(43), + logs_bloom: vec![0; 256].try_into().unwrap(), + prev_randao, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp, + extra_data: VariableList::empty(), + base_fee_per_gas: Uint256::from(1u64), + block_hash: ExecutionBlockHash::zero(), + transactions: VariableList::empty(), + }; + transition_payload.block_hash = + ExecutionBlockHash::from_root(transition_payload.tree_hash_root()); + + // Insert the transition payload into the mock EL so subsequent blocks can chain. + { + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let mut block_gen = mock_el.server.execution_block_generator(); + block_gen.insert_block_without_checks(execution_layer::test_utils::Block::PoS( + ExecutionPayload::Bellatrix(transition_payload.clone()), + )); + } + + harness + .build_and_import_block_with_payload(&mut state, slot, transition_payload) + .await; + state = harness.get_current_state(); + + assert!( + is_merge_transition_complete(&state), + "merge should be complete after slot {merge_slot}" + ); + } + + // Phase 3: slots merge_slot+1 to total_slots — use harness with attestations. + let post_merge_slots = (total_slots - merge_slot) as usize; + harness.extend_slots(post_merge_slots).await; + + // ---- Verification: check all blocks in the store against plan invariants ---- + + let mut prev_payload_block_hash: Option<ExecutionBlockHash> = None; + + for slot_num in 1..=total_slots { + let slot = Slot::new(slot_num); + let block_root = harness + .chain + .block_root_at_slot(slot, WhenSlotSkipped::Prev) + .unwrap() + .unwrap_or_else(|| panic!("missing block at slot {slot_num}")); + let block = store + .get_blinded_block(&block_root) + .unwrap() + .unwrap_or_else(|| panic!("block not in store at slot {slot_num}")); + let payload = block + .message() + .body() + .execution_payload() + .expect("bellatrix block should have execution payload"); + + if slot_num < merge_slot { + // Slots 1 to merge_slot-1: payload must be default. + assert!( + payload.is_default_with_empty_roots(), + "slot {slot_num} should have default payload" + ); + } else if slot_num == merge_slot { + // Merge transition block: first non-default payload. + assert!( + !payload.is_default_with_empty_roots(), + "slot {slot_num} (merge) should have non-default payload" + ); + prev_payload_block_hash = Some(payload.block_hash()); + } else { + // Post-merge: non-default payload with valid parent_hash chain. + assert!( + !payload.is_default_with_empty_roots(), + "slot {slot_num} should have non-default payload" + ); + assert_eq!( + payload.parent_hash(), + prev_payload_block_hash.unwrap(), + "slot {slot_num} payload parent_hash should chain from previous payload" + ); + prev_payload_block_hash = Some(payload.block_hash()); + } + } + + // Verify finalization. + let finalized_epoch = harness + .chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch; + assert!( + finalized_epoch > 0, + "chain should have finalized past genesis" + ); +} + fn get_finalized_epoch_boundary_blocks( dump: &[BeaconSnapshot<MinimalEthSpec, BlindedPayload<MinimalEthSpec>>], ) -> HashSet<SignedBeaconBlockHash> { diff --git a/beacon_node/beacon_chain/tests/tests.rs b/beacon_node/beacon_chain/tests/tests.rs index 17d9c5f697..3958ce6c6d 100644 --- a/beacon_node/beacon_chain/tests/tests.rs +++ b/beacon_node/beacon_chain/tests/tests.rs @@ -3,6 +3,7 @@ use beacon_chain::{ BeaconChain, ChainConfig, NotifyExecutionLayer, StateSkipConfig, WhenSlotSkipped, attestation_verification::Error as AttnError, + custody_context::NodeCustodyType, test_utils::{ AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, OP_POOL_DB_KEY, @@ -14,7 +15,8 @@ use state_processing::EpochProcessingError; use state_processing::{per_slot_processing, per_slot_processing::Error as SlotProcessingError}; use std::sync::LazyLock; use types::{ - BeaconState, BeaconStateError, BlockImportSource, Checkpoint, EthSpec, Hash256, MinimalEthSpec, + BeaconState, BeaconStateError, BlockImportSource, ChainSpec, Checkpoint, + DEFAULT_PRE_ELECTRA_WS_PERIOD, EthSpec, ForkName, Hash256, MainnetEthSpec, MinimalEthSpec, RelativeEpoch, Slot, }; @@ -31,12 +33,33 @@ fn get_harness(validator_count: usize) -> BeaconChainHarness<EphemeralHarnessTyp get_harness_with_config( validator_count, ChainConfig { - reconstruct_historic_states: true, + archive: true, ..Default::default() }, ) } +fn get_harness_with_spec( + validator_count: usize, + spec: &ChainSpec, +) -> BeaconChainHarness<EphemeralHarnessType<MainnetEthSpec>> { + let chain_config = ChainConfig { + archive: true, + ..Default::default() + }; + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.clone().into()) + .chain_config(chain_config) + .keypairs(KEYPAIRS[0..validator_count].to_vec()) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.advance_slot(); + + harness +} + fn get_harness_with_config( validator_count: usize, chain_config: ChainConfig, @@ -54,6 +77,28 @@ fn get_harness_with_config( harness } +/// Creates a harness with SemiSupernode custody type to ensure enough columns are stored +/// for sampling validation in Fulu. +fn get_harness_semi_supernode( + validator_count: usize, +) -> BeaconChainHarness<EphemeralHarnessType<MinimalEthSpec>> { + let harness = BeaconChainHarness::builder(MinimalEthSpec) + .default_spec() + .chain_config(ChainConfig { + archive: true, + ..Default::default() + }) + .keypairs(KEYPAIRS[0..validator_count].to_vec()) + .fresh_ephemeral_store() + .mock_execution_layer() + .node_custody_type(NodeCustodyType::SemiSupernode) + .build(); + + harness.advance_slot(); + + harness +} + #[test] fn massive_skips() { let harness = get_harness(8); @@ -70,7 +115,18 @@ fn massive_skips() { assert!(state.slot() > 1, "the state should skip at least one slot"); - if state.fork_name_unchecked().fulu_enabled() { + if state.fork_name_unchecked().gloas_enabled() { + // Gloas uses compute_balance_weighted_selection for proposer selection, which + // returns InvalidIndicesCount (not InsufficientValidators) when the active + // validator set is empty. + assert_eq!( + error, + SlotProcessingError::EpochProcessingError(EpochProcessingError::BeaconStateError( + BeaconStateError::InvalidIndicesCount + )), + "should return error indicating that validators have been slashed out" + ) + } else if state.fork_name_unchecked().fulu_enabled() { // post-fulu this is done in per_epoch_processing assert_eq!( error, @@ -545,7 +601,10 @@ async fn unaggregated_attestations_added_to_fork_choice_some_none() { if slot <= num_blocks_produced && slot != 0 { assert_eq!( - latest_message.unwrap().1, + latest_message + .expect("latest message should be present") + .slot + .epoch(MinimalEthSpec::slots_per_epoch()), slot.epoch(MinimalEthSpec::slots_per_epoch()), "Latest message epoch for {} should be equal to epoch {}.", validator, @@ -655,10 +714,12 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { let validator_slots: Vec<(&usize, Slot)> = validators.iter().zip(slots).collect(); for (validator, slot) in validator_slots { - let latest_message = fork_choice.latest_message(*validator); + let latest_message = fork_choice + .latest_message(*validator) + .expect("latest message should be present"); assert_eq!( - latest_message.unwrap().1, + latest_message.slot.epoch(MinimalEthSpec::slots_per_epoch()), slot.epoch(MinimalEthSpec::slots_per_epoch()), "Latest message slot should be equal to attester duty." ); @@ -669,8 +730,7 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { .expect("Should get block root at slot"); assert_eq!( - latest_message.unwrap().0, - *block_root, + latest_message.root, *block_root, "Latest message block root should be equal to block at slot." ); } @@ -679,8 +739,9 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { async fn run_skip_slot_test(skip_slots: u64) { let num_validators = 8; - let harness_a = get_harness(num_validators); - let harness_b = get_harness(num_validators); + // SemiSupernode ensures enough columns are stored for sampling + custody RpcBlock validation + let harness_a = get_harness_semi_supernode(num_validators); + let harness_b = get_harness_semi_supernode(num_validators); for _ in 0..skip_slots { harness_a.advance_slot(); @@ -904,7 +965,7 @@ async fn pseudo_finalize_test_generic( let num_blocks_produced = MinimalEthSpec::slots_per_epoch() * 5; let chain_config = ChainConfig { - reconstruct_historic_states: true, + archive: true, epochs_per_migration, ..Default::default() }; @@ -956,9 +1017,12 @@ async fn pseudo_finalize_test_generic( }; // pseudo finalize + // Post-Gloas the finalized state must be Pending (the block's state_root), not Full + // (the envelope's state_root), because the payload of the finalized block is not finalized. + let finalized_state_root = head.beacon_block.message().state_root(); harness .chain - .manually_finalize_state(head.beacon_state_root(), checkpoint) + .manually_finalize_state(finalized_state_root, checkpoint) .unwrap(); let split = harness.chain.store.get_split_info(); @@ -1059,3 +1123,28 @@ async fn pseudo_finalize_with_lagging_split_update() { let expect_true_migration = false; pseudo_finalize_test_generic(epochs_per_migration, expect_true_migration).await; } + +#[tokio::test] +async fn test_compute_weak_subjectivity_period() { + type E = MainnetEthSpec; + let expected_ws_period_pre_electra = DEFAULT_PRE_ELECTRA_WS_PERIOD; + let expected_ws_period_post_electra = 256; + + // test Base variant + let spec = ForkName::Altair.make_genesis_spec(E::default_spec()); + let harness = get_harness_with_spec(VALIDATOR_COUNT, &spec); + let head_state = harness.get_current_state(); + + let calculated_ws_period = head_state.compute_weak_subjectivity_period(&spec).unwrap(); + + assert_eq!(calculated_ws_period, expected_ws_period_pre_electra); + + // test Electra variant + let spec = ForkName::Electra.make_genesis_spec(E::default_spec()); + let harness = get_harness_with_spec(VALIDATOR_COUNT, &spec); + let head_state = harness.get_current_state(); + + let calculated_ws_period = head_state.compute_weak_subjectivity_period(&spec).unwrap(); + + assert_eq!(calculated_ws_period, expected_ws_period_post_electra); +} diff --git a/beacon_node/beacon_chain/tests/validator_monitor.rs b/beacon_node/beacon_chain/tests/validator_monitor.rs index 521fc4ac97..a37ab6458f 100644 --- a/beacon_node/beacon_chain/tests/validator_monitor.rs +++ b/beacon_node/beacon_chain/tests/validator_monitor.rs @@ -117,7 +117,8 @@ async fn missed_blocks_across_epochs() { #[tokio::test] async fn missed_blocks_basic() { - let validator_count = 16; + // >= 32 validators required for Gloas genesis with MainnetEthSpec (32 slots/epoch). + let validator_count = 32; let slots_per_epoch = E::slots_per_epoch(); diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index da6acfaf2e..347e092b9a 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -38,20 +38,22 @@ //! checks the queues to see if there are more parcels of work that can be spawned in a new worker //! task. +pub use crate::scheduler::BeaconProcessorQueueLengths; +use crate::scheduler::work_queue::WorkQueues; use crate::work_reprocessing_queue::{ - QueuedBackfillBatch, QueuedColumnReconstruction, QueuedGossipBlock, ReprocessQueueMessage, + QueuedBackfillBatch, QueuedColumnReconstruction, QueuedGossipBlock, QueuedGossipEnvelope, + ReprocessQueueMessage, }; use futures::stream::{Stream, StreamExt}; use futures::task::Poll; use lighthouse_network::{MessageId, NetworkGlobals, PeerId}; -use logging::TimeLatch; use logging::crit; use parking_lot::Mutex; pub use scheduler::work_reprocessing_queue; use serde::{Deserialize, Serialize}; use slot_clock::SlotClock; use std::cmp; -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::fmt; use std::future::Future; use std::pin::Pin; @@ -63,10 +65,7 @@ use task_executor::{RayonPoolType, TaskExecutor}; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TrySendError; use tracing::{debug, error, trace, warn}; -use types::{ - BeaconState, ChainSpec, EthSpec, Hash256, RelativeEpoch, SignedAggregateAndProof, - SingleAttestation, Slot, SubnetId, -}; +use types::{EthSpec, Hash256, SignedAggregateAndProof, SingleAttestation, Slot, SubnetId}; use work_reprocessing_queue::IgnoredRpcBlock; use work_reprocessing_queue::{ QueuedAggregate, QueuedLightClientUpdate, QueuedRpcBlock, QueuedUnaggregate, ReadyWork, @@ -90,127 +89,6 @@ const MAX_IDLE_QUEUE_LEN: usize = 16_384; /// The maximum size of the channel for re-processing work events. const DEFAULT_MAX_SCHEDULED_WORK_QUEUE_LEN: usize = 3 * DEFAULT_MAX_WORK_EVENT_QUEUE_LEN / 4; -/// Over-provision queues based on active validator count by some factor. The beacon chain has -/// strict churns that prevent the validator set size from changing rapidly. By over-provisioning -/// slightly, we don't need to adjust the queues during the lifetime of a process. -const ACTIVE_VALIDATOR_COUNT_OVERPROVISION_PERCENT: usize = 110; - -/// Minimum size of dynamically sized queues. Due to integer division we don't want 0 length queues -/// as the processor won't process that message type. 128 is an arbitrary value value >= 1 that -/// seems reasonable. -const MIN_QUEUE_LEN: usize = 128; - -/// Maximum number of queued items that will be stored before dropping them -pub struct BeaconProcessorQueueLengths { - aggregate_queue: usize, - attestation_queue: usize, - unknown_block_aggregate_queue: usize, - unknown_block_attestation_queue: usize, - sync_message_queue: usize, - sync_contribution_queue: usize, - gossip_voluntary_exit_queue: usize, - gossip_proposer_slashing_queue: usize, - gossip_attester_slashing_queue: usize, - unknown_light_client_update_queue: usize, - rpc_block_queue: usize, - rpc_blob_queue: usize, - rpc_custody_column_queue: usize, - column_reconstruction_queue: usize, - chain_segment_queue: usize, - backfill_chain_segment: usize, - gossip_block_queue: usize, - gossip_blob_queue: usize, - gossip_data_column_queue: usize, - delayed_block_queue: usize, - status_queue: usize, - block_brange_queue: usize, - block_broots_queue: usize, - blob_broots_queue: usize, - blob_brange_queue: usize, - dcbroots_queue: usize, - dcbrange_queue: usize, - gossip_bls_to_execution_change_queue: usize, - lc_gossip_finality_update_queue: usize, - lc_gossip_optimistic_update_queue: usize, - lc_bootstrap_queue: usize, - lc_rpc_optimistic_update_queue: usize, - lc_rpc_finality_update_queue: usize, - lc_update_range_queue: usize, - gossip_inclusion_list_queue: usize, - api_request_p0_queue: usize, - api_request_p1_queue: usize, -} - -impl BeaconProcessorQueueLengths { - pub fn from_state<E: EthSpec>( - state: &BeaconState<E>, - spec: &ChainSpec, - ) -> Result<Self, String> { - let active_validator_count = - match state.get_cached_active_validator_indices(RelativeEpoch::Current) { - Ok(indices) => indices.len(), - Err(_) => state - .get_active_validator_indices(state.current_epoch(), spec) - .map_err(|e| format!("Error computing active indices: {:?}", e))? - .len(), - }; - let active_validator_count = - (ACTIVE_VALIDATOR_COUNT_OVERPROVISION_PERCENT * active_validator_count) / 100; - let slots_per_epoch = E::slots_per_epoch() as usize; - - Ok(Self { - aggregate_queue: 4096, - unknown_block_aggregate_queue: 1024, - // Capacity for a full slot's worth of attestations if subscribed to all subnets - attestation_queue: std::cmp::max( - active_validator_count / slots_per_epoch, - MIN_QUEUE_LEN, - ), - // Capacity for a full slot's worth of attestations if subscribed to all subnets - unknown_block_attestation_queue: std::cmp::max( - active_validator_count / slots_per_epoch, - MIN_QUEUE_LEN, - ), - sync_message_queue: 2048, - sync_contribution_queue: 1024, - gossip_voluntary_exit_queue: 4096, - gossip_proposer_slashing_queue: 4096, - gossip_attester_slashing_queue: 4096, - unknown_light_client_update_queue: 128, - rpc_block_queue: 1024, - rpc_blob_queue: 1024, - // We don't request more than `PARENT_DEPTH_TOLERANCE` (32) lookups, so we can limit - // this queue size. With 48 max blobs per block, each column sidecar list could be up to 12MB. - rpc_custody_column_queue: 64, - column_reconstruction_queue: 1, - chain_segment_queue: 64, - backfill_chain_segment: 64, - gossip_block_queue: 1024, - gossip_blob_queue: 1024, - gossip_data_column_queue: 1024, - delayed_block_queue: 1024, - status_queue: 1024, - block_brange_queue: 1024, - block_broots_queue: 1024, - blob_broots_queue: 1024, - blob_brange_queue: 1024, - dcbroots_queue: 1024, - dcbrange_queue: 1024, - gossip_bls_to_execution_change_queue: 16384, - lc_gossip_finality_update_queue: 1024, - lc_gossip_optimistic_update_queue: 1024, - lc_bootstrap_queue: 1024, - lc_rpc_optimistic_update_queue: 512, - lc_rpc_finality_update_queue: 512, - lc_update_range_queue: 512, - // TODO(focil) pick proper values - gossip_inclusion_list_queue: 64, - api_request_p0_queue: 1024, - api_request_p1_queue: 1024, - }) - } -} - /// The name of the manager tokio task. const MANAGER_TASK_NAME: &str = "beacon_processor_manager"; @@ -282,89 +160,6 @@ impl<E: EthSpec> Default for BeaconProcessorChannels<E> { } } -/// A simple first-in-first-out queue with a maximum length. -struct FifoQueue<T> { - queue: VecDeque<T>, - max_length: usize, -} - -impl<T> FifoQueue<T> { - /// Create a new, empty queue with the given length. - pub fn new(max_length: usize) -> Self { - Self { - queue: VecDeque::default(), - max_length, - } - } - - /// Add a new item to the queue. - /// - /// Drops `item` if the queue is full. - pub fn push(&mut self, item: T, item_desc: &str) { - if self.queue.len() == self.max_length { - error!( - msg = "the system has insufficient resources for load", - queue_len = self.max_length, - queue = item_desc, - "Work queue is full" - ) - } else { - self.queue.push_back(item); - } - } - - /// Remove the next item from the queue. - pub fn pop(&mut self) -> Option<T> { - self.queue.pop_front() - } - - /// Returns the current length of the queue. - pub fn len(&self) -> usize { - self.queue.len() - } -} - -/// A simple last-in-first-out queue with a maximum length. -struct LifoQueue<T> { - queue: VecDeque<T>, - max_length: usize, -} - -impl<T> LifoQueue<T> { - /// Create a new, empty queue with the given length. - pub fn new(max_length: usize) -> Self { - Self { - queue: VecDeque::default(), - max_length, - } - } - - /// Add a new item to the front of the queue. - /// - /// If the queue is full, the item at the back of the queue is dropped. - pub fn push(&mut self, item: T) { - if self.queue.len() == self.max_length { - self.queue.pop_back(); - } - self.queue.push_front(item); - } - - /// Remove the next item from the queue. - pub fn pop(&mut self) -> Option<T> { - self.queue.pop_front() - } - - /// Returns `true` if the queue is full. - pub fn is_full(&self) -> bool { - self.queue.len() >= self.max_length - } - - /// Returns the current length of the queue. - pub fn len(&self) -> usize { - self.queue.len() - } -} - /// A handle that sends a message on the provided channel to a receiver when it gets dropped. /// /// The receiver task is responsible for removing the provided `entry` from the `DuplicateCache` @@ -448,13 +243,28 @@ impl<E: EthSpec> From<ReadyWork> for WorkEvent<E> { process_fn, }, }, + ReadyWork::Envelope(QueuedGossipEnvelope { + beacon_block_slot, + beacon_block_root, + process_fn, + }) => Self { + drop_during_sync: false, + work: Work::DelayedImportEnvelope { + beacon_block_slot, + beacon_block_root, + process_fn, + }, + }, ReadyWork::RpcBlock(QueuedRpcBlock { - beacon_block_root: _, + beacon_block_root, process_fn, ignore_fn: _, }) => Self { drop_during_sync: false, - work: Work::RpcBlock { process_fn }, + work: Work::RpcBlock { + process_fn, + beacon_block_root, + }, }, ReadyWork::IgnoredRpcBlock(IgnoredRpcBlock { process_fn }) => Self { drop_during_sync: false, @@ -582,11 +392,17 @@ pub enum Work<E: EthSpec> { GossipBlock(AsyncFn), GossipBlobSidecar(AsyncFn), GossipDataColumnSidecar(AsyncFn), + GossipPartialDataColumnSidecar(AsyncFn), DelayedImportBlock { beacon_block_slot: Slot, beacon_block_root: Hash256, process_fn: AsyncFn, }, + DelayedImportEnvelope { + beacon_block_slot: Slot, + beacon_block_root: Hash256, + process_fn: AsyncFn, + }, GossipVoluntaryExit(BlockingFn), GossipProposerSlashing(BlockingFn), GossipAttesterSlashing(BlockingFn), @@ -595,6 +411,7 @@ pub enum Work<E: EthSpec> { GossipLightClientFinalityUpdate(BlockingFn), GossipLightClientOptimisticUpdate(BlockingFn), RpcBlock { + beacon_block_root: Hash256, process_fn: AsyncFn, }, RpcBlobs { @@ -605,16 +422,26 @@ pub enum Work<E: EthSpec> { IgnoredRpcBlock { process_fn: BlockingFn, }, - ChainSegment(AsyncFn), + ChainSegment { + process_fn: AsyncFn, + /// (chain_id, batch_epoch) for test observability + process_id: (u32, u64), + }, ChainSegmentBackfill(BlockingFn), Status(BlockingFn), BlocksByRangeRequest(AsyncFn), BlocksByRootsRequest(AsyncFn), + PayloadEnvelopesByRangeRequest(AsyncFn), + PayloadEnvelopesByRootRequest(AsyncFn), BlobsByRangeRequest(BlockingFn), BlobsByRootsRequest(BlockingFn), DataColumnsByRootsRequest(BlockingFn), DataColumnsByRangeRequest(BlockingFn), GossipBlsToExecutionChange(BlockingFn), + GossipExecutionPayload(AsyncFn), + GossipExecutionPayloadBid(BlockingFn), + GossipPayloadAttestation(BlockingFn), + GossipProposerPreferences(BlockingFn), LightClientBootstrapRequest(BlockingFn), LightClientOptimisticUpdateRequest(BlockingFn), LightClientFinalityUpdateRequest(BlockingFn), @@ -645,7 +472,9 @@ pub enum WorkType { GossipBlock, GossipBlobSidecar, GossipDataColumnSidecar, + GossipPartialDataColumnSidecar, DelayedImportBlock, + DelayedImportEnvelope, GossipVoluntaryExit, GossipProposerSlashing, GossipAttesterSlashing, @@ -663,11 +492,17 @@ pub enum WorkType { Status, BlocksByRangeRequest, BlocksByRootsRequest, + PayloadEnvelopesByRangeRequest, + PayloadEnvelopesByRootRequest, BlobsByRangeRequest, BlobsByRootsRequest, DataColumnsByRootsRequest, DataColumnsByRangeRequest, GossipBlsToExecutionChange, + GossipExecutionPayload, + GossipExecutionPayloadBid, + GossipPayloadAttestation, + GossipProposerPreferences, LightClientBootstrapRequest, LightClientOptimisticUpdateRequest, LightClientFinalityUpdateRequest, @@ -679,7 +514,7 @@ pub enum WorkType { } impl<E: EthSpec> Work<E> { - fn str_id(&self) -> &'static str { + pub fn str_id(&self) -> &'static str { self.to_type().into() } @@ -693,7 +528,9 @@ impl<E: EthSpec> Work<E> { Work::GossipBlock(_) => WorkType::GossipBlock, Work::GossipBlobSidecar(_) => WorkType::GossipBlobSidecar, Work::GossipDataColumnSidecar(_) => WorkType::GossipDataColumnSidecar, + Work::GossipPartialDataColumnSidecar(_) => WorkType::GossipPartialDataColumnSidecar, Work::DelayedImportBlock { .. } => WorkType::DelayedImportBlock, + Work::DelayedImportEnvelope { .. } => WorkType::DelayedImportEnvelope, Work::GossipVoluntaryExit(_) => WorkType::GossipVoluntaryExit, Work::GossipProposerSlashing(_) => WorkType::GossipProposerSlashing, Work::GossipAttesterSlashing(_) => WorkType::GossipAttesterSlashing, @@ -704,6 +541,10 @@ impl<E: EthSpec> Work<E> { WorkType::GossipLightClientOptimisticUpdate } Work::GossipBlsToExecutionChange(_) => WorkType::GossipBlsToExecutionChange, + Work::GossipExecutionPayload(_) => WorkType::GossipExecutionPayload, + Work::GossipExecutionPayloadBid(_) => WorkType::GossipExecutionPayloadBid, + Work::GossipPayloadAttestation(_) => WorkType::GossipPayloadAttestation, + Work::GossipProposerPreferences(_) => WorkType::GossipProposerPreferences, Work::RpcBlock { .. } => WorkType::RpcBlock, Work::RpcBlobs { .. } => WorkType::RpcBlobs, Work::RpcCustodyColumn { .. } => WorkType::RpcCustodyColumn, @@ -714,6 +555,8 @@ impl<E: EthSpec> Work<E> { Work::Status(_) => WorkType::Status, Work::BlocksByRangeRequest(_) => WorkType::BlocksByRangeRequest, Work::BlocksByRootsRequest(_) => WorkType::BlocksByRootsRequest, + Work::PayloadEnvelopesByRangeRequest(_) => WorkType::PayloadEnvelopesByRangeRequest, + Work::PayloadEnvelopesByRootRequest(_) => WorkType::PayloadEnvelopesByRootRequest, Work::BlobsByRangeRequest(_) => WorkType::BlobsByRangeRequest, Work::BlobsByRootsRequest(_) => WorkType::BlobsByRootsRequest, Work::DataColumnsByRootsRequest(_) => WorkType::DataColumnsByRootsRequest, @@ -840,76 +683,8 @@ impl<E: EthSpec> BeaconProcessor<E> { // Used by workers to communicate that they are finished a task. let (idle_tx, idle_rx) = mpsc::channel::<WorkType>(MAX_IDLE_QUEUE_LEN); - // Using LIFO queues for attestations since validator profits rely upon getting fresh - // attestations into blocks. Additionally, later attestations contain more information than - // earlier ones, so we consider them more valuable. - let mut aggregate_queue = LifoQueue::new(queue_lengths.aggregate_queue); - let mut aggregate_debounce = TimeLatch::default(); - let mut attestation_queue = LifoQueue::new(queue_lengths.attestation_queue); - let mut attestation_to_convert_queue = LifoQueue::new(queue_lengths.attestation_queue); - let mut attestation_debounce = TimeLatch::default(); - let mut unknown_block_aggregate_queue = - LifoQueue::new(queue_lengths.unknown_block_aggregate_queue); - let mut unknown_block_attestation_queue = - LifoQueue::new(queue_lengths.unknown_block_attestation_queue); - - let mut sync_message_queue = LifoQueue::new(queue_lengths.sync_message_queue); - let mut sync_contribution_queue = LifoQueue::new(queue_lengths.sync_contribution_queue); - - // Using a FIFO queue for voluntary exits since it prevents exit censoring. I don't have - // a strong feeling about queue type for exits. - let mut gossip_voluntary_exit_queue = - FifoQueue::new(queue_lengths.gossip_voluntary_exit_queue); - - // Using a FIFO queue for slashing to prevent people from flushing their slashings from the - // queues with lots of junk messages. - let mut gossip_proposer_slashing_queue = - FifoQueue::new(queue_lengths.gossip_proposer_slashing_queue); - let mut gossip_attester_slashing_queue = - FifoQueue::new(queue_lengths.gossip_attester_slashing_queue); - - // Using a FIFO queue since blocks need to be imported sequentially. - let mut rpc_block_queue = FifoQueue::new(queue_lengths.rpc_block_queue); - let mut rpc_blob_queue = FifoQueue::new(queue_lengths.rpc_blob_queue); - let mut rpc_custody_column_queue = FifoQueue::new(queue_lengths.rpc_custody_column_queue); - let mut column_reconstruction_queue = - LifoQueue::new(queue_lengths.column_reconstruction_queue); - let mut chain_segment_queue = FifoQueue::new(queue_lengths.chain_segment_queue); - let mut backfill_chain_segment = FifoQueue::new(queue_lengths.backfill_chain_segment); - let mut gossip_block_queue = FifoQueue::new(queue_lengths.gossip_block_queue); - let mut gossip_blob_queue = FifoQueue::new(queue_lengths.gossip_blob_queue); - let mut gossip_data_column_queue = FifoQueue::new(queue_lengths.gossip_data_column_queue); - let mut delayed_block_queue = FifoQueue::new(queue_lengths.delayed_block_queue); - - let mut status_queue = FifoQueue::new(queue_lengths.status_queue); - let mut block_brange_queue = FifoQueue::new(queue_lengths.block_brange_queue); - let mut block_broots_queue = FifoQueue::new(queue_lengths.block_broots_queue); - let mut blob_broots_queue = FifoQueue::new(queue_lengths.blob_broots_queue); - let mut blob_brange_queue = FifoQueue::new(queue_lengths.blob_brange_queue); - let mut dcbroots_queue = FifoQueue::new(queue_lengths.dcbroots_queue); - let mut dcbrange_queue = FifoQueue::new(queue_lengths.dcbrange_queue); - - let mut gossip_bls_to_execution_change_queue = - FifoQueue::new(queue_lengths.gossip_bls_to_execution_change_queue); - - // Using FIFO queues for light client updates to maintain sequence order. - let mut lc_gossip_finality_update_queue = - FifoQueue::new(queue_lengths.lc_gossip_finality_update_queue); - let mut lc_gossip_optimistic_update_queue = - FifoQueue::new(queue_lengths.lc_gossip_optimistic_update_queue); - let mut unknown_light_client_update_queue = - FifoQueue::new(queue_lengths.unknown_light_client_update_queue); - let mut lc_bootstrap_queue = FifoQueue::new(queue_lengths.lc_bootstrap_queue); - let mut lc_rpc_optimistic_update_queue = - FifoQueue::new(queue_lengths.lc_rpc_optimistic_update_queue); - let mut lc_rpc_finality_update_queue = - FifoQueue::new(queue_lengths.lc_rpc_finality_update_queue); - let mut lc_update_range_queue = FifoQueue::new(queue_lengths.lc_update_range_queue); - - let mut gossip_inclusion_list_queue = - FifoQueue::new(queue_lengths.gossip_inclusion_list_queue); - let mut api_request_p0_queue = FifoQueue::new(queue_lengths.api_request_p0_queue); - let mut api_request_p1_queue = FifoQueue::new(queue_lengths.api_request_p1_queue); + // Initialize the worker queues. + let mut work_queues: WorkQueues<E> = WorkQueues::new(queue_lengths); // Channels for sending work to the re-process scheduler (`work_reprocessing_tx`) and to // receive them back once they are ready (`ready_work_rx`). @@ -1038,230 +813,273 @@ impl<E: EthSpec> BeaconProcessor<E> { None if can_spawn => { // Check for chain segments first, they're the most efficient way to get // blocks into the system. - let work_event: Option<Work<E>> = - if let Some(item) = chain_segment_queue.pop() { - Some(item) - // Check sync blocks before gossip blocks, since we've already explicitly - // requested these blocks. - } else if let Some(item) = rpc_block_queue.pop() { - Some(item) - } else if let Some(item) = rpc_blob_queue.pop() { - Some(item) - } else if let Some(item) = rpc_custody_column_queue.pop() { - Some(item) - } else if let Some(item) = rpc_custody_column_queue.pop() { - Some(item) - // Check delayed blocks before gossip blocks, the gossip blocks might rely - // on the delayed ones. - } else if let Some(item) = delayed_block_queue.pop() { - Some(item) - // Check gossip blocks before gossip attestations, since a block might be - // required to verify some attestations. - } else if let Some(item) = gossip_block_queue.pop() { - Some(item) - } else if let Some(item) = gossip_blob_queue.pop() { - Some(item) - } else if let Some(item) = gossip_data_column_queue.pop() { - Some(item) - } else if let Some(item) = column_reconstruction_queue.pop() { - Some(item) - // Check the priority 0 API requests after blocks and blobs, but before attestations. - } else if let Some(item) = api_request_p0_queue.pop() { - Some(item) - // Check the aggregates, *then* the unaggregates since we assume that - // aggregates are more valuable to local validators and effectively give us - // more information with less signature verification time. - } else if aggregate_queue.len() > 0 { - let batch_size = cmp::min( - aggregate_queue.len(), - self.config.max_gossip_aggregate_batch_size, - ); + let work_event: Option<Work<E>> = if let Some(item) = + work_queues.chain_segment_queue.pop() + { + Some(item) + // Check sync blocks before gossip blocks, since we've already explicitly + // requested these blocks. + } else if let Some(item) = work_queues.rpc_block_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.rpc_blob_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.rpc_custody_column_queue.pop() { + Some(item) + // Check delayed blocks before gossip blocks, the gossip blocks might rely + // on the delayed ones. + } else if let Some(item) = work_queues.delayed_block_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.delayed_envelope_queue.pop() { + Some(item) + // Check gossip blocks and payloads before gossip attestations, since a block might be + // required to verify some attestations. + } else if let Some(item) = work_queues.gossip_block_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.gossip_execution_payload_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.gossip_blob_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.gossip_data_column_queue.pop() { + Some(item) + } else if let Some(item) = + work_queues.gossip_partial_data_column_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.column_reconstruction_queue.pop() { + Some(item) + // Check the priority 0 API requests after blocks and blobs, but before attestations. + } else if let Some(item) = work_queues.api_request_p0_queue.pop() { + Some(item) + // Check the aggregates, *then* the unaggregates since we assume that + // aggregates are more valuable to local validators and effectively give us + // more information with less signature verification time. + } else if !work_queues.aggregate_queue.is_empty() { + let batch_size = cmp::min( + work_queues.aggregate_queue.len(), + self.config.max_gossip_aggregate_batch_size, + ); - if batch_size < 2 { - // One single aggregate is in the queue, process it individually. - aggregate_queue.pop() - } else { - // Collect two or more aggregates into a batch, so they can take - // advantage of batch signature verification. - // - // Note: this will convert the `Work::GossipAggregate` item into a - // `Work::GossipAggregateBatch` item. - let mut aggregates = Vec::with_capacity(batch_size); - let mut process_batch_opt = None; - for _ in 0..batch_size { - if let Some(item) = aggregate_queue.pop() { - match item { - Work::GossipAggregate { - aggregate, - process_individual: _, - process_batch, - } => { - aggregates.push(*aggregate); - if process_batch_opt.is_none() { - process_batch_opt = Some(process_batch); - } - } - _ => { - error!("Invalid item in aggregate queue"); - } - } - } - } - - if let Some(process_batch) = process_batch_opt { - // Process all aggregates with a single worker. - Some(Work::GossipAggregateBatch { - aggregates, - process_batch, - }) - } else { - // There is no good reason for this to - // happen, it is a serious logic error. - // Since we only form batches when multiple - // work items exist, we should always have a - // work closure at this point. - crit!("Missing aggregate work"); - None - } - } - // Check the unaggregated attestation queue. - // - // Potentially use batching. - } else if attestation_queue.len() > 0 { - let batch_size = cmp::min( - attestation_queue.len(), - self.config.max_gossip_attestation_batch_size, - ); - - if batch_size < 2 { - // One single attestation is in the queue, process it individually. - attestation_queue.pop() - } else { - // Collect two or more attestations into a batch, so they can take - // advantage of batch signature verification. - // - // Note: this will convert the `Work::GossipAttestation` item into a - // `Work::GossipAttestationBatch` item. - let mut attestations = Vec::with_capacity(batch_size); - let mut process_batch_opt = None; - for _ in 0..batch_size { - if let Some(item) = attestation_queue.pop() { - match item { - Work::GossipAttestation { - attestation, - process_individual: _, - process_batch, - } => { - attestations.push(*attestation); - if process_batch_opt.is_none() { - process_batch_opt = Some(process_batch); - } - } - _ => error!("Invalid item in attestation queue"), - } - } - } - - if let Some(process_batch) = process_batch_opt { - // Process all attestations with a single worker. - Some(Work::GossipAttestationBatch { - attestations, - process_batch, - }) - } else { - // There is no good reason for this to - // happen, it is a serious logic error. - // Since we only form batches when multiple - // work items exist, we should always have a - // work closure at this point. - crit!("Missing attestations work"); - None - } - } - } else if let Some(item) = gossip_inclusion_list_queue.pop() { - Some(item) - // Convert any gossip attestations that need to be converted. - } else if let Some(item) = attestation_to_convert_queue.pop() { - Some(item) - // Check sync committee messages after attestations as their rewards are lesser - // and they don't influence fork choice. - } else if let Some(item) = sync_contribution_queue.pop() { - Some(item) - } else if let Some(item) = sync_message_queue.pop() { - Some(item) - // Aggregates and unaggregates queued for re-processing are older and we - // care about fresher ones, so check those first. - } else if let Some(item) = unknown_block_aggregate_queue.pop() { - Some(item) - } else if let Some(item) = unknown_block_attestation_queue.pop() { - Some(item) - // Check RPC methods next. Status messages are needed for sync so - // prioritize them over syncing requests from other peers (BlocksByRange - // and BlocksByRoot) - } else if let Some(item) = status_queue.pop() { - Some(item) - } else if let Some(item) = block_brange_queue.pop() { - Some(item) - } else if let Some(item) = block_broots_queue.pop() { - Some(item) - } else if let Some(item) = blob_brange_queue.pop() { - Some(item) - } else if let Some(item) = blob_broots_queue.pop() { - Some(item) - } else if let Some(item) = dcbroots_queue.pop() { - Some(item) - } else if let Some(item) = dcbrange_queue.pop() { - Some(item) - // Check slashings after all other consensus messages so we prioritize - // following head. - // - // Check attester slashings before proposer slashings since they have the - // potential to slash multiple validators at once. - } else if let Some(item) = gossip_attester_slashing_queue.pop() { - Some(item) - } else if let Some(item) = gossip_proposer_slashing_queue.pop() { - Some(item) - // Check exits and address changes late since our validators don't get - // rewards from them. - } else if let Some(item) = gossip_voluntary_exit_queue.pop() { - Some(item) - } else if let Some(item) = gossip_bls_to_execution_change_queue.pop() { - Some(item) - // Check the priority 1 API requests after we've - // processed all the interesting things from the network - // and things required for us to stay in good repute - // with our P2P peers. - } else if let Some(item) = api_request_p1_queue.pop() { - Some(item) - // Handle backfill sync chain segments. - } else if let Some(item) = backfill_chain_segment.pop() { - Some(item) - // Handle light client requests. - } else if let Some(item) = lc_gossip_finality_update_queue.pop() { - Some(item) - } else if let Some(item) = lc_gossip_optimistic_update_queue.pop() { - Some(item) - } else if let Some(item) = unknown_light_client_update_queue.pop() { - Some(item) - } else if let Some(item) = lc_bootstrap_queue.pop() { - Some(item) - } else if let Some(item) = lc_rpc_optimistic_update_queue.pop() { - Some(item) - } else if let Some(item) = lc_rpc_finality_update_queue.pop() { - Some(item) - } else if let Some(item) = lc_update_range_queue.pop() { - Some(item) - // This statement should always be the final else statement. + if batch_size < 2 { + // One single aggregate is in the queue, process it individually. + work_queues.aggregate_queue.pop() } else { - // Let the journal know that a worker is freed and there's nothing else - // for it to do. - if let Some(work_journal_tx) = &work_journal_tx { - // We don't care if this message was successfully sent, we only use the journal - // during testing. - let _ = work_journal_tx.try_send(NOTHING_TO_DO); + // Collect two or more aggregates into a batch, so they can take + // advantage of batch signature verification. + // + // Note: this will convert the `Work::GossipAggregate` item into a + // `Work::GossipAggregateBatch` item. + let mut aggregates = Vec::with_capacity(batch_size); + let mut process_batch_opt = None; + for _ in 0..batch_size { + if let Some(item) = work_queues.aggregate_queue.pop() { + match item { + Work::GossipAggregate { + aggregate, + process_individual: _, + process_batch, + } => { + aggregates.push(*aggregate); + if process_batch_opt.is_none() { + process_batch_opt = Some(process_batch); + } + } + _ => { + error!("Invalid item in aggregate queue"); + } + } + } } - None - }; + + if let Some(process_batch) = process_batch_opt { + // Process all aggregates with a single worker. + Some(Work::GossipAggregateBatch { + aggregates, + process_batch, + }) + } else { + // There is no good reason for this to + // happen, it is a serious logic error. + // Since we only form batches when multiple + // work items exist, we should always have a + // work closure at this point. + crit!("Missing aggregate work"); + None + } + } + // Check the unaggregated attestation queue. + // + // Potentially use batching. + } else if !work_queues.attestation_queue.is_empty() { + let batch_size = cmp::min( + work_queues.attestation_queue.len(), + self.config.max_gossip_attestation_batch_size, + ); + + if batch_size < 2 { + // One single attestation is in the queue, process it individually. + work_queues.attestation_queue.pop() + } else { + // Collect two or more attestations into a batch, so they can take + // advantage of batch signature verification. + // + // Note: this will convert the `Work::GossipAttestation` item into a + // `Work::GossipAttestationBatch` item. + let mut attestations = Vec::with_capacity(batch_size); + let mut process_batch_opt = None; + for _ in 0..batch_size { + if let Some(item) = work_queues.attestation_queue.pop() { + match item { + Work::GossipAttestation { + attestation, + process_individual: _, + process_batch, + } => { + attestations.push(*attestation); + if process_batch_opt.is_none() { + process_batch_opt = Some(process_batch); + } + } + _ => error!("Invalid item in attestation queue"), + } + } + } + + if let Some(process_batch) = process_batch_opt { + // Process all attestations with a single worker. + Some(Work::GossipAttestationBatch { + attestations, + process_batch, + }) + } else { + // There is no good reason for this to + // happen, it is a serious logic error. + // Since we only form batches when multiple + // work items exist, we should always have a + // work closure at this point. + crit!("Missing attestations work"); + None + } + } + // Check inclusion lists after attestations since they're time-sensitive. + } else if let Some(item) = work_queues.gossip_inclusion_list_queue.pop() { + Some(item) + // Convert any gossip attestations that need to be converted. + } else if let Some(item) = work_queues.attestation_to_convert_queue.pop() { + Some(item) + // Check payload attestation messages after attestations. They dont give rewards + // but they influence fork choice. + } else if let Some(item) = + work_queues.gossip_payload_attestation_queue.pop() + { + Some(item) + // Check sync committee messages after attestations as their rewards are lesser + // and they don't influence fork choice. + } else if let Some(item) = work_queues.sync_contribution_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.sync_message_queue.pop() { + Some(item) + // Aggregates and unaggregates queued for re-processing are older and we + // care about fresher ones, so check those first. + } else if let Some(item) = work_queues.unknown_block_aggregate_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.unknown_block_attestation_queue.pop() + { + Some(item) + // Check execution payload bids. Most proposers will request bids directly from builders + // instead of receiving them over gossip. + } else if let Some(item) = + work_queues.gossip_execution_payload_bid_queue.pop() + { + Some(item) + // Check proposer preferences. + } else if let Some(item) = + work_queues.gossip_proposer_preferences_queue.pop() + { + Some(item) + // Check RPC methods next. Status messages are needed for sync so + // prioritize them over syncing requests from other peers (BlocksByRange + // and BlocksByRoot) + } else if let Some(item) = work_queues.status_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.block_brange_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.block_broots_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.blob_brange_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.blob_broots_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.dcbroots_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.dcbrange_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.payload_envelopes_brange_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.payload_envelopes_broots_queue.pop() + { + Some(item) + // Check slashings after all other consensus messages so we prioritize + // following head. + // + // Check attester slashings before proposer slashings since they have the + // potential to slash multiple validators at once. + } else if let Some(item) = work_queues.gossip_attester_slashing_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.gossip_proposer_slashing_queue.pop() + { + Some(item) + // Check exits and address changes late since our validators don't get + // rewards from them. + } else if let Some(item) = work_queues.gossip_voluntary_exit_queue.pop() { + Some(item) + } else if let Some(item) = + work_queues.gossip_bls_to_execution_change_queue.pop() + { + Some(item) + // Check the priority 1 API requests after we've + // processed all the interesting things from the network + // and things required for us to stay in good repute + // with our P2P peers. + } else if let Some(item) = work_queues.api_request_p1_queue.pop() { + Some(item) + // Handle backfill sync chain segments. + } else if let Some(item) = work_queues.backfill_chain_segment.pop() { + Some(item) + // Handle light client requests. + } else if let Some(item) = work_queues.lc_gossip_finality_update_queue.pop() + { + Some(item) + } else if let Some(item) = + work_queues.lc_gossip_optimistic_update_queue.pop() + { + Some(item) + } else if let Some(item) = + work_queues.unknown_light_client_update_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.lc_bootstrap_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.lc_rpc_optimistic_update_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.lc_rpc_finality_update_queue.pop() { + Some(item) + } else if let Some(item) = work_queues.lc_update_range_queue.pop() { + Some(item) + // This statement should always be the final else statement. + } else { + // Let the journal know that a worker is freed and there's nothing else + // for it to do. + if let Some(work_journal_tx) = &work_journal_tx { + // We don't care if this message was successfully sent, we only use the journal + // during testing. + let _ = work_journal_tx.try_send(NOTHING_TO_DO); + } + None + }; if let Some(work_event) = work_event { let work_type = work_event.to_type(); @@ -1314,14 +1132,16 @@ impl<E: EthSpec> BeaconProcessor<E> { } } _ if can_spawn => self.spawn_worker(work, created_timestamp, idle_tx), - Work::GossipAttestation { .. } => attestation_queue.push(work), + Work::GossipAttestation { .. } => { + work_queues.attestation_queue.push(work) + } // Attestation batches are formed internally within the // `BeaconProcessor`, they are not sent from external services. Work::GossipAttestationBatch { .. } => crit!( work_type = "GossipAttestationBatch", "Unsupported inbound event" ), - Work::GossipAggregate { .. } => aggregate_queue.push(work), + Work::GossipAggregate { .. } => work_queues.aggregate_queue.push(work), // Aggregate batches are formed internally within the `BeaconProcessor`, // they are not sent from external services. Work::GossipAggregateBatch { .. } => { @@ -1330,93 +1150,131 @@ impl<E: EthSpec> BeaconProcessor<E> { "Unsupported inbound event" ) } - Work::GossipBlock { .. } => gossip_block_queue.push(work, work_id), - Work::GossipBlobSidecar { .. } => gossip_blob_queue.push(work, work_id), - Work::GossipDataColumnSidecar { .. } => { - gossip_data_column_queue.push(work, work_id) + Work::GossipBlock { .. } => { + work_queues.gossip_block_queue.push(work, work_id) } + Work::GossipBlobSidecar { .. } => { + work_queues.gossip_blob_queue.push(work, work_id) + } + Work::GossipDataColumnSidecar { .. } => { + work_queues.gossip_data_column_queue.push(work, work_id) + } + Work::GossipPartialDataColumnSidecar { .. } => work_queues + .gossip_partial_data_column_queue + .push(work, work_id), Work::DelayedImportBlock { .. } => { - delayed_block_queue.push(work, work_id) + work_queues.delayed_block_queue.push(work, work_id) + } + Work::DelayedImportEnvelope { .. } => { + work_queues.delayed_envelope_queue.push(work, work_id) } Work::GossipInclusionList { .. } => { - gossip_inclusion_list_queue.push(work, work_id) + work_queues.gossip_inclusion_list_queue.push(work, work_id) } Work::GossipVoluntaryExit { .. } => { - gossip_voluntary_exit_queue.push(work, work_id) + work_queues.gossip_voluntary_exit_queue.push(work, work_id) } - Work::GossipProposerSlashing { .. } => { - gossip_proposer_slashing_queue.push(work, work_id) + Work::GossipProposerSlashing { .. } => work_queues + .gossip_proposer_slashing_queue + .push(work, work_id), + Work::GossipAttesterSlashing { .. } => work_queues + .gossip_attester_slashing_queue + .push(work, work_id), + Work::GossipSyncSignature { .. } => { + work_queues.sync_message_queue.push(work) } - Work::GossipAttesterSlashing { .. } => { - gossip_attester_slashing_queue.push(work, work_id) - } - Work::GossipSyncSignature { .. } => sync_message_queue.push(work), Work::GossipSyncContribution { .. } => { - sync_contribution_queue.push(work) - } - Work::GossipLightClientFinalityUpdate { .. } => { - lc_gossip_finality_update_queue.push(work, work_id) - } - Work::GossipLightClientOptimisticUpdate { .. } => { - lc_gossip_optimistic_update_queue.push(work, work_id) + work_queues.sync_contribution_queue.push(work) } + Work::GossipLightClientFinalityUpdate { .. } => work_queues + .lc_gossip_finality_update_queue + .push(work, work_id), + Work::GossipLightClientOptimisticUpdate { .. } => work_queues + .lc_gossip_optimistic_update_queue + .push(work, work_id), Work::RpcBlock { .. } | Work::IgnoredRpcBlock { .. } => { - rpc_block_queue.push(work, work_id) + work_queues.rpc_block_queue.push(work, work_id) } - Work::RpcBlobs { .. } => rpc_blob_queue.push(work, work_id), + Work::RpcBlobs { .. } => work_queues.rpc_blob_queue.push(work, work_id), Work::RpcCustodyColumn { .. } => { - rpc_custody_column_queue.push(work, work_id) + work_queues.rpc_custody_column_queue.push(work, work_id) + } + Work::ColumnReconstruction(_) => { + work_queues.column_reconstruction_queue.push(work) + } + Work::ChainSegment { .. } => { + work_queues.chain_segment_queue.push(work, work_id) } - Work::ColumnReconstruction(_) => column_reconstruction_queue.push(work), - Work::ChainSegment { .. } => chain_segment_queue.push(work, work_id), Work::ChainSegmentBackfill { .. } => { - backfill_chain_segment.push(work, work_id) + work_queues.backfill_chain_segment.push(work, work_id) } - Work::Status { .. } => status_queue.push(work, work_id), + Work::Status { .. } => work_queues.status_queue.push(work, work_id), Work::BlocksByRangeRequest { .. } => { - block_brange_queue.push(work, work_id) + work_queues.block_brange_queue.push(work, work_id) } Work::BlocksByRootsRequest { .. } => { - block_broots_queue.push(work, work_id) + work_queues.block_broots_queue.push(work, work_id) } + Work::PayloadEnvelopesByRangeRequest { .. } => work_queues + .payload_envelopes_brange_queue + .push(work, work_id), + Work::PayloadEnvelopesByRootRequest { .. } => work_queues + .payload_envelopes_broots_queue + .push(work, work_id), Work::BlobsByRangeRequest { .. } => { - blob_brange_queue.push(work, work_id) + work_queues.blob_brange_queue.push(work, work_id) } Work::LightClientBootstrapRequest { .. } => { - lc_bootstrap_queue.push(work, work_id) - } - Work::LightClientOptimisticUpdateRequest { .. } => { - lc_rpc_optimistic_update_queue.push(work, work_id) + work_queues.lc_bootstrap_queue.push(work, work_id) } + Work::LightClientOptimisticUpdateRequest { .. } => work_queues + .lc_rpc_optimistic_update_queue + .push(work, work_id), Work::LightClientFinalityUpdateRequest { .. } => { - lc_rpc_finality_update_queue.push(work, work_id) + work_queues.lc_rpc_finality_update_queue.push(work, work_id) } Work::LightClientUpdatesByRangeRequest { .. } => { - lc_update_range_queue.push(work, work_id) + work_queues.lc_update_range_queue.push(work, work_id) } Work::UnknownBlockAttestation { .. } => { - unknown_block_attestation_queue.push(work) + work_queues.unknown_block_attestation_queue.push(work) } Work::UnknownBlockAggregate { .. } => { - unknown_block_aggregate_queue.push(work) - } - Work::GossipBlsToExecutionChange { .. } => { - gossip_bls_to_execution_change_queue.push(work, work_id) + work_queues.unknown_block_aggregate_queue.push(work) } + Work::GossipBlsToExecutionChange { .. } => work_queues + .gossip_bls_to_execution_change_queue + .push(work, work_id), + Work::GossipExecutionPayload { .. } => work_queues + .gossip_execution_payload_queue + .push(work, work_id), + Work::GossipExecutionPayloadBid { .. } => work_queues + .gossip_execution_payload_bid_queue + .push(work, work_id), + Work::GossipPayloadAttestation { .. } => work_queues + .gossip_payload_attestation_queue + .push(work, work_id), + Work::GossipProposerPreferences { .. } => work_queues + .gossip_proposer_preferences_queue + .push(work, work_id), Work::BlobsByRootsRequest { .. } => { - blob_broots_queue.push(work, work_id) + work_queues.blob_broots_queue.push(work, work_id) } Work::DataColumnsByRootsRequest { .. } => { - dcbroots_queue.push(work, work_id) + work_queues.dcbroots_queue.push(work, work_id) } Work::DataColumnsByRangeRequest { .. } => { - dcbrange_queue.push(work, work_id) + work_queues.dcbrange_queue.push(work, work_id) } - Work::UnknownLightClientOptimisticUpdate { .. } => { - unknown_light_client_update_queue.push(work, work_id) + Work::UnknownLightClientOptimisticUpdate { .. } => work_queues + .unknown_light_client_update_queue + .push(work, work_id), + Work::ApiRequestP0 { .. } => { + work_queues.api_request_p0_queue.push(work, work_id) + } + Work::ApiRequestP1 { .. } => { + work_queues.api_request_p1_queue.push(work, work_id) } - Work::ApiRequestP0 { .. } => api_request_p0_queue.push(work, work_id), - Work::ApiRequestP1 { .. } => api_request_p1_queue.push(work, work_id), }; Some(work_type) } @@ -1424,58 +1282,106 @@ impl<E: EthSpec> BeaconProcessor<E> { if let Some(modified_queue_id) = modified_queue_id { let queue_len = match modified_queue_id { - WorkType::GossipAttestation => attestation_queue.len(), - WorkType::GossipAttestationToConvert => attestation_to_convert_queue.len(), - WorkType::UnknownBlockAttestation => unknown_block_attestation_queue.len(), + WorkType::GossipAttestation => work_queues.attestation_queue.len(), + WorkType::GossipAttestationToConvert => { + work_queues.attestation_to_convert_queue.len() + } + WorkType::UnknownBlockAttestation => { + work_queues.unknown_block_attestation_queue.len() + } WorkType::GossipAttestationBatch => 0, // No queue - WorkType::GossipAggregate => aggregate_queue.len(), - WorkType::UnknownBlockAggregate => unknown_block_aggregate_queue.len(), + WorkType::GossipAggregate => work_queues.aggregate_queue.len(), + WorkType::UnknownBlockAggregate => { + work_queues.unknown_block_aggregate_queue.len() + } WorkType::UnknownLightClientOptimisticUpdate => { - unknown_light_client_update_queue.len() + work_queues.unknown_light_client_update_queue.len() } WorkType::GossipAggregateBatch => 0, // No queue - WorkType::GossipBlock => gossip_block_queue.len(), - WorkType::GossipBlobSidecar => gossip_blob_queue.len(), - WorkType::GossipDataColumnSidecar => gossip_data_column_queue.len(), - WorkType::DelayedImportBlock => delayed_block_queue.len(), - WorkType::GossipVoluntaryExit => gossip_voluntary_exit_queue.len(), - WorkType::GossipProposerSlashing => gossip_proposer_slashing_queue.len(), - WorkType::GossipAttesterSlashing => gossip_attester_slashing_queue.len(), - WorkType::GossipSyncSignature => sync_message_queue.len(), - WorkType::GossipSyncContribution => sync_contribution_queue.len(), + WorkType::GossipBlock => work_queues.gossip_block_queue.len(), + WorkType::GossipBlobSidecar => work_queues.gossip_blob_queue.len(), + WorkType::GossipDataColumnSidecar => { + work_queues.gossip_data_column_queue.len() + } + WorkType::GossipPartialDataColumnSidecar => { + work_queues.gossip_partial_data_column_queue.len() + } + WorkType::DelayedImportBlock => work_queues.delayed_block_queue.len(), + WorkType::DelayedImportEnvelope => work_queues.delayed_envelope_queue.len(), + WorkType::GossipVoluntaryExit => { + work_queues.gossip_voluntary_exit_queue.len() + } + WorkType::GossipProposerSlashing => { + work_queues.gossip_proposer_slashing_queue.len() + } + WorkType::GossipAttesterSlashing => { + work_queues.gossip_attester_slashing_queue.len() + } + WorkType::GossipSyncSignature => work_queues.sync_message_queue.len(), + WorkType::GossipSyncContribution => { + work_queues.sync_contribution_queue.len() + } WorkType::GossipLightClientFinalityUpdate => { - lc_gossip_finality_update_queue.len() + work_queues.lc_gossip_finality_update_queue.len() } WorkType::GossipLightClientOptimisticUpdate => { - lc_gossip_optimistic_update_queue.len() + work_queues.lc_gossip_optimistic_update_queue.len() } - WorkType::RpcBlock => rpc_block_queue.len(), - WorkType::RpcBlobs | WorkType::IgnoredRpcBlock => rpc_blob_queue.len(), - WorkType::RpcCustodyColumn => rpc_custody_column_queue.len(), - WorkType::ColumnReconstruction => column_reconstruction_queue.len(), - WorkType::ChainSegment => chain_segment_queue.len(), - WorkType::ChainSegmentBackfill => backfill_chain_segment.len(), - WorkType::Status => status_queue.len(), - WorkType::BlocksByRangeRequest => block_brange_queue.len(), - WorkType::BlocksByRootsRequest => block_broots_queue.len(), - WorkType::BlobsByRangeRequest => blob_brange_queue.len(), - WorkType::BlobsByRootsRequest => blob_broots_queue.len(), - WorkType::DataColumnsByRootsRequest => dcbroots_queue.len(), - WorkType::DataColumnsByRangeRequest => dcbrange_queue.len(), + WorkType::RpcBlock => work_queues.rpc_block_queue.len(), + WorkType::RpcBlobs | WorkType::IgnoredRpcBlock => { + work_queues.rpc_blob_queue.len() + } + WorkType::RpcCustodyColumn => work_queues.rpc_custody_column_queue.len(), + WorkType::ColumnReconstruction => { + work_queues.column_reconstruction_queue.len() + } + WorkType::ChainSegment => work_queues.chain_segment_queue.len(), + WorkType::ChainSegmentBackfill => work_queues.backfill_chain_segment.len(), + WorkType::Status => work_queues.status_queue.len(), + WorkType::BlocksByRangeRequest => work_queues.block_brange_queue.len(), + WorkType::BlocksByRootsRequest => work_queues.block_broots_queue.len(), + WorkType::PayloadEnvelopesByRangeRequest => { + work_queues.payload_envelopes_brange_queue.len() + } + WorkType::PayloadEnvelopesByRootRequest => { + work_queues.payload_envelopes_broots_queue.len() + } + WorkType::BlobsByRangeRequest => work_queues.blob_brange_queue.len(), + WorkType::BlobsByRootsRequest => work_queues.blob_broots_queue.len(), + WorkType::DataColumnsByRootsRequest => work_queues.dcbroots_queue.len(), + WorkType::DataColumnsByRangeRequest => work_queues.dcbrange_queue.len(), WorkType::GossipBlsToExecutionChange => { - gossip_bls_to_execution_change_queue.len() + work_queues.gossip_bls_to_execution_change_queue.len() + } + WorkType::GossipExecutionPayload => { + work_queues.gossip_execution_payload_queue.len() + } + WorkType::GossipExecutionPayloadBid => { + work_queues.gossip_execution_payload_bid_queue.len() + } + WorkType::GossipPayloadAttestation => { + work_queues.gossip_payload_attestation_queue.len() + } + WorkType::GossipProposerPreferences => { + work_queues.gossip_proposer_preferences_queue.len() + } + WorkType::LightClientBootstrapRequest => { + work_queues.lc_bootstrap_queue.len() } - WorkType::LightClientBootstrapRequest => lc_bootstrap_queue.len(), WorkType::LightClientOptimisticUpdateRequest => { - lc_rpc_optimistic_update_queue.len() + work_queues.lc_rpc_optimistic_update_queue.len() } WorkType::LightClientFinalityUpdateRequest => { - lc_rpc_finality_update_queue.len() + work_queues.lc_rpc_finality_update_queue.len() } - WorkType::GossipInclusionList => gossip_inclusion_list_queue.len(), - WorkType::LightClientUpdatesByRangeRequest => lc_update_range_queue.len(), - WorkType::ApiRequestP0 => api_request_p0_queue.len(), - WorkType::ApiRequestP1 => api_request_p1_queue.len(), + WorkType::GossipInclusionList => { + work_queues.gossip_inclusion_list_queue.len() + } + WorkType::LightClientUpdatesByRangeRequest => { + work_queues.lc_update_range_queue.len() + } + WorkType::ApiRequestP0 => work_queues.api_request_p0_queue.len(), + WorkType::ApiRequestP1 => work_queues.api_request_p1_queue.len(), WorkType::Reprocess => 0, }; metrics::observe_vec( @@ -1485,18 +1391,21 @@ impl<E: EthSpec> BeaconProcessor<E> { ); } - if aggregate_queue.is_full() && aggregate_debounce.elapsed() { + if work_queues.aggregate_queue.is_full() && work_queues.aggregate_debounce.elapsed() + { error!( msg = "the system has insufficient resources for load", - queue_len = aggregate_queue.max_length, + queue_len = work_queues.aggregate_queue.max_length, "Aggregate attestation queue full" ) } - if attestation_queue.is_full() && attestation_debounce.elapsed() { + if work_queues.attestation_queue.is_full() + && work_queues.attestation_debounce.elapsed() + { error!( msg = "the system has insufficient resources for load", - queue_len = attestation_queue.max_length, + queue_len = work_queues.attestation_queue.max_length, "Attestation queue full" ) } @@ -1593,7 +1502,7 @@ impl<E: EthSpec> BeaconProcessor<E> { } => task_spawner.spawn_blocking(move || { process_batch(aggregates); }), - Work::ChainSegment(process_fn) => task_spawner.spawn_async(async move { + Work::ChainSegment { process_fn, .. } => task_spawner.spawn_async(async move { process_fn.await; }), Work::UnknownBlockAttestation { process_fn } @@ -1605,15 +1514,25 @@ impl<E: EthSpec> BeaconProcessor<E> { beacon_block_slot: _, beacon_block_root: _, process_fn, + } + | Work::DelayedImportEnvelope { + beacon_block_slot: _, + beacon_block_root: _, + process_fn, } => task_spawner.spawn_async(process_fn), - Work::RpcBlock { process_fn } + Work::RpcBlock { + process_fn, + beacon_block_root: _, + } | Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) | Work::ColumnReconstruction(process_fn) => task_spawner.spawn_async(process_fn), Work::IgnoredRpcBlock { process_fn } => task_spawner.spawn_blocking(process_fn), Work::GossipBlock(work) | Work::GossipBlobSidecar(work) - | Work::GossipDataColumnSidecar(work) => task_spawner.spawn_async(async move { + | Work::GossipDataColumnSidecar(work) + | Work::GossipPartialDataColumnSidecar(work) + | Work::GossipExecutionPayload(work) => task_spawner.spawn_async(async move { work.await; }), Work::BlobsByRangeRequest(process_fn) @@ -1622,9 +1541,10 @@ impl<E: EthSpec> BeaconProcessor<E> { | Work::DataColumnsByRangeRequest(process_fn) => { task_spawner.spawn_blocking(process_fn) } - Work::BlocksByRangeRequest(work) | Work::BlocksByRootsRequest(work) => { - task_spawner.spawn_async(work) - } + Work::BlocksByRangeRequest(work) + | Work::BlocksByRootsRequest(work) + | Work::PayloadEnvelopesByRangeRequest(work) + | Work::PayloadEnvelopesByRootRequest(work) => task_spawner.spawn_async(work), Work::ChainSegmentBackfill(process_fn) => { if self.config.enable_backfill_rate_limiting { task_spawner.spawn_blocking_with_rayon(RayonPoolType::LowPriority, process_fn) @@ -1647,6 +1567,9 @@ impl<E: EthSpec> BeaconProcessor<E> { | Work::Status(process_fn) | Work::GossipBlsToExecutionChange(process_fn) | Work::GossipInclusionList(process_fn) + | Work::GossipExecutionPayloadBid(process_fn) + | Work::GossipPayloadAttestation(process_fn) + | Work::GossipProposerPreferences(process_fn) | Work::LightClientBootstrapRequest(process_fn) | Work::LightClientOptimisticUpdateRequest(process_fn) | Work::LightClientFinalityUpdateRequest(process_fn) @@ -1745,21 +1668,3 @@ impl Drop for SendOnDrop { } } } - -#[cfg(test)] -mod tests { - use super::*; - use types::{BeaconState, ChainSpec, Eth1Data, ForkName, MainnetEthSpec}; - - #[test] - fn min_queue_len() { - // State with no validators. - let spec = ForkName::latest_stable().make_genesis_spec(ChainSpec::mainnet()); - let genesis_time = 0; - let state = BeaconState::<MainnetEthSpec>::new(genesis_time, Eth1Data::default(), &spec); - assert_eq!(state.validators().len(), 0); - let queue_lengths = BeaconProcessorQueueLengths::from_state(&state, &spec).unwrap(); - assert_eq!(queue_lengths.attestation_queue, MIN_QUEUE_LEN); - assert_eq!(queue_lengths.unknown_block_attestation_queue, MIN_QUEUE_LEN); - } -} diff --git a/beacon_node/beacon_processor/src/scheduler/mod.rs b/beacon_node/beacon_processor/src/scheduler/mod.rs index e1a076a7c5..96393f9f3b 100644 --- a/beacon_node/beacon_processor/src/scheduler/mod.rs +++ b/beacon_node/beacon_processor/src/scheduler/mod.rs @@ -1 +1,4 @@ +pub mod work_queue; pub mod work_reprocessing_queue; + +pub use work_queue::BeaconProcessorQueueLengths; diff --git a/beacon_node/beacon_processor/src/scheduler/work_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_queue.rs new file mode 100644 index 0000000000..084b45bf18 --- /dev/null +++ b/beacon_node/beacon_processor/src/scheduler/work_queue.rs @@ -0,0 +1,448 @@ +use crate::Work; +use logging::TimeLatch; +use std::collections::VecDeque; +use tracing::error; +use types::{BeaconState, ChainSpec, EthSpec, RelativeEpoch}; + +/// Over-provision queues based on active validator count by some factor. The beacon chain has +/// strict churns that prevent the validator set size from changing rapidly. By over-provisioning +/// slightly, we don't need to adjust the queues during the lifetime of a process. +const ACTIVE_VALIDATOR_COUNT_OVERPROVISION_PERCENT: usize = 110; + +/// Minimum size of dynamically sized queues. Due to integer division we don't want 0 length queues +/// as the processor won't process that message type. 128 is an arbitrary value value >= 1 that +/// seems reasonable. +const MIN_QUEUE_LEN: usize = 128; + +/// A simple first-in-first-out queue with a maximum length. +pub struct FifoQueue<T> { + queue: VecDeque<T>, + max_length: usize, +} + +impl<T> FifoQueue<T> { + /// Create a new, empty queue with the given length. + pub fn new(max_length: usize) -> Self { + Self { + queue: VecDeque::default(), + max_length, + } + } + + /// Add a new item to the queue. + /// + /// Drops `item` if the queue is full. + pub fn push(&mut self, item: T, item_desc: &str) { + if self.queue.len() == self.max_length { + error!( + queue = item_desc, + queue_len = self.max_length, + msg = "the system has insufficient resources for load", + "Work queue is full", + ) + } else { + self.queue.push_back(item); + } + } + + /// Remove the next item from the queue. + pub fn pop(&mut self) -> Option<T> { + self.queue.pop_front() + } + + /// Returns the current length of the queue. + pub fn len(&self) -> usize { + self.queue.len() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } +} + +/// A simple last-in-first-out queue with a maximum length. +pub struct LifoQueue<T> { + queue: VecDeque<T>, + pub max_length: usize, +} + +impl<T> LifoQueue<T> { + /// Create a new, empty queue with the given length. + pub fn new(max_length: usize) -> Self { + Self { + queue: VecDeque::default(), + max_length, + } + } + + /// Add a new item to the front of the queue. + /// + /// If the queue is full, the item at the back of the queue is dropped. + pub fn push(&mut self, item: T) { + if self.queue.len() == self.max_length { + self.queue.pop_back(); + } + self.queue.push_front(item); + } + + /// Remove the next item from the queue. + pub fn pop(&mut self) -> Option<T> { + self.queue.pop_front() + } + + /// Returns `true` if the queue is full. + pub fn is_full(&self) -> bool { + self.queue.len() >= self.max_length + } + + /// Returns the current length of the queue. + pub fn len(&self) -> usize { + self.queue.len() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } +} + +/// Maximum number of queued items that will be stored before dropping them +pub struct BeaconProcessorQueueLengths { + aggregate_queue: usize, + attestation_queue: usize, + unknown_block_aggregate_queue: usize, + unknown_block_attestation_queue: usize, + sync_message_queue: usize, + sync_contribution_queue: usize, + gossip_voluntary_exit_queue: usize, + gossip_proposer_slashing_queue: usize, + gossip_attester_slashing_queue: usize, + unknown_light_client_update_queue: usize, + rpc_block_queue: usize, + rpc_blob_queue: usize, + rpc_custody_column_queue: usize, + column_reconstruction_queue: usize, + chain_segment_queue: usize, + backfill_chain_segment: usize, + gossip_block_queue: usize, + gossip_blob_queue: usize, + gossip_data_column_queue: usize, + gossip_partial_data_column_queue: usize, + delayed_block_queue: usize, + delayed_envelope_queue: usize, + status_queue: usize, + block_brange_queue: usize, + block_broots_queue: usize, + blob_broots_queue: usize, + blob_brange_queue: usize, + dcbroots_queue: usize, + dcbrange_queue: usize, + payload_envelopes_brange_queue: usize, + payload_envelopes_broots_queue: usize, + gossip_bls_to_execution_change_queue: usize, + gossip_execution_payload_queue: usize, + gossip_execution_payload_bid_queue: usize, + gossip_payload_attestation_queue: usize, + gossip_proposer_preferences_queue: usize, + lc_bootstrap_queue: usize, + lc_rpc_optimistic_update_queue: usize, + lc_rpc_finality_update_queue: usize, + lc_gossip_finality_update_queue: usize, + lc_gossip_optimistic_update_queue: usize, + lc_update_range_queue: usize, + // TODO(focil) pick proper values + gossip_inclusion_list_queue: usize, + api_request_p0_queue: usize, + api_request_p1_queue: usize, +} + +impl BeaconProcessorQueueLengths { + pub fn from_state<E: EthSpec>( + state: &BeaconState<E>, + spec: &ChainSpec, + ) -> Result<Self, String> { + let active_validator_count = + match state.get_cached_active_validator_indices(RelativeEpoch::Current) { + Ok(indices) => indices.len(), + Err(_) => state + .get_active_validator_indices(state.current_epoch(), spec) + .map_err(|e| format!("Error computing active indices: {:?}", e))? + .len(), + }; + let active_validator_count = + (ACTIVE_VALIDATOR_COUNT_OVERPROVISION_PERCENT * active_validator_count) / 100; + let slots_per_epoch = E::slots_per_epoch() as usize; + + Ok(Self { + aggregate_queue: 4096, + unknown_block_aggregate_queue: 1024, + // Capacity for a full slot's worth of attestations if subscribed to all subnets + attestation_queue: std::cmp::max( + active_validator_count / slots_per_epoch, + MIN_QUEUE_LEN, + ), + // Capacity for a full slot's worth of attestations if subscribed to all subnets + unknown_block_attestation_queue: std::cmp::max( + active_validator_count / slots_per_epoch, + MIN_QUEUE_LEN, + ), + sync_message_queue: 2048, + sync_contribution_queue: 1024, + gossip_voluntary_exit_queue: 4096, + gossip_proposer_slashing_queue: 4096, + gossip_attester_slashing_queue: 4096, + unknown_light_client_update_queue: 128, + rpc_block_queue: 1024, + rpc_blob_queue: 1024, + // We don't request more than `PARENT_DEPTH_TOLERANCE` (32) lookups, so we can limit + // this queue size. With 48 max blobs per block, each column sidecar list could be up to 12MB. + rpc_custody_column_queue: 64, + column_reconstruction_queue: 1, + chain_segment_queue: 64, + backfill_chain_segment: 64, + gossip_block_queue: 1024, + gossip_blob_queue: 1024, + gossip_data_column_queue: 1024, + gossip_partial_data_column_queue: 1024, + delayed_block_queue: 1024, + delayed_envelope_queue: 1024, + status_queue: 1024, + block_brange_queue: 1024, + block_broots_queue: 1024, + blob_broots_queue: 1024, + blob_brange_queue: 1024, + dcbroots_queue: 1024, + dcbrange_queue: 1024, + payload_envelopes_brange_queue: 1024, + payload_envelopes_broots_queue: 1024, + gossip_bls_to_execution_change_queue: 16384, + // TODO(EIP-7732): verify 1024 is preferable. I used same value as `gossip_block_queue` and `gossip_blob_queue` + gossip_execution_payload_queue: 1024, + // TODO(EIP-7732) how big should this queue be? + gossip_execution_payload_bid_queue: 1024, + // PTC size ~512 per slot, buffer 2-3 slots for reorgs and processing delays (512 * 3 = 1536) + // TODO(EIP-7732): verify if this is preferable queue length or otherwise + gossip_payload_attestation_queue: 1536, + // TODO(EIP-7732): verify if this is preferable queue length + gossip_proposer_preferences_queue: 1024, + lc_gossip_finality_update_queue: 1024, + lc_gossip_optimistic_update_queue: 1024, + lc_bootstrap_queue: 1024, + lc_rpc_optimistic_update_queue: 512, + lc_rpc_finality_update_queue: 512, + lc_update_range_queue: 512, + gossip_inclusion_list_queue: 64, + api_request_p0_queue: 1024, + api_request_p1_queue: 1024, + }) + } +} + +pub struct WorkQueues<E: EthSpec> { + pub aggregate_queue: LifoQueue<Work<E>>, + pub aggregate_debounce: TimeLatch, + pub attestation_queue: LifoQueue<Work<E>>, + pub attestation_to_convert_queue: LifoQueue<Work<E>>, + pub attestation_debounce: TimeLatch, + pub unknown_block_aggregate_queue: LifoQueue<Work<E>>, + pub unknown_block_attestation_queue: LifoQueue<Work<E>>, + pub sync_message_queue: LifoQueue<Work<E>>, + pub sync_contribution_queue: LifoQueue<Work<E>>, + pub gossip_voluntary_exit_queue: FifoQueue<Work<E>>, + pub gossip_proposer_slashing_queue: FifoQueue<Work<E>>, + pub gossip_attester_slashing_queue: FifoQueue<Work<E>>, + pub unknown_light_client_update_queue: FifoQueue<Work<E>>, + pub rpc_block_queue: FifoQueue<Work<E>>, + pub rpc_blob_queue: FifoQueue<Work<E>>, + pub rpc_custody_column_queue: FifoQueue<Work<E>>, + pub column_reconstruction_queue: LifoQueue<Work<E>>, + pub chain_segment_queue: FifoQueue<Work<E>>, + pub backfill_chain_segment: FifoQueue<Work<E>>, + pub gossip_block_queue: FifoQueue<Work<E>>, + pub gossip_blob_queue: FifoQueue<Work<E>>, + pub gossip_data_column_queue: FifoQueue<Work<E>>, + pub gossip_partial_data_column_queue: FifoQueue<Work<E>>, + pub delayed_block_queue: FifoQueue<Work<E>>, + pub delayed_envelope_queue: FifoQueue<Work<E>>, + pub status_queue: FifoQueue<Work<E>>, + pub block_brange_queue: FifoQueue<Work<E>>, + pub block_broots_queue: FifoQueue<Work<E>>, + pub payload_envelopes_brange_queue: FifoQueue<Work<E>>, + pub payload_envelopes_broots_queue: FifoQueue<Work<E>>, + pub blob_broots_queue: FifoQueue<Work<E>>, + pub blob_brange_queue: FifoQueue<Work<E>>, + pub dcbroots_queue: FifoQueue<Work<E>>, + pub dcbrange_queue: FifoQueue<Work<E>>, + pub gossip_bls_to_execution_change_queue: FifoQueue<Work<E>>, + pub gossip_execution_payload_queue: FifoQueue<Work<E>>, + pub gossip_execution_payload_bid_queue: FifoQueue<Work<E>>, + pub gossip_payload_attestation_queue: FifoQueue<Work<E>>, + pub gossip_proposer_preferences_queue: FifoQueue<Work<E>>, + pub lc_gossip_finality_update_queue: FifoQueue<Work<E>>, + pub lc_gossip_optimistic_update_queue: FifoQueue<Work<E>>, + pub lc_bootstrap_queue: FifoQueue<Work<E>>, + pub lc_rpc_optimistic_update_queue: FifoQueue<Work<E>>, + pub lc_rpc_finality_update_queue: FifoQueue<Work<E>>, + pub lc_update_range_queue: FifoQueue<Work<E>>, + pub gossip_inclusion_list_queue: FifoQueue<Work<E>>, + pub api_request_p0_queue: FifoQueue<Work<E>>, + pub api_request_p1_queue: FifoQueue<Work<E>>, +} + +impl<E: EthSpec> WorkQueues<E> { + pub fn new(queue_lengths: BeaconProcessorQueueLengths) -> Self { + // Using LIFO queues for attestations since validator profits rely upon getting fresh + // attestations into blocks. Additionally, later attestations contain more information than + // earlier ones, so we consider them more valuable. + let aggregate_queue = LifoQueue::new(queue_lengths.aggregate_queue); + let aggregate_debounce = TimeLatch::default(); + let attestation_queue = LifoQueue::new(queue_lengths.attestation_queue); + let attestation_to_convert_queue = LifoQueue::new(queue_lengths.attestation_queue); + let attestation_debounce = TimeLatch::default(); + let unknown_block_aggregate_queue = + LifoQueue::new(queue_lengths.unknown_block_aggregate_queue); + let unknown_block_attestation_queue = + LifoQueue::new(queue_lengths.unknown_block_attestation_queue); + + let sync_message_queue = LifoQueue::new(queue_lengths.sync_message_queue); + let sync_contribution_queue = LifoQueue::new(queue_lengths.sync_contribution_queue); + + // Using a FIFO queue for voluntary exits since it prevents exit censoring. I don't have + // a strong feeling about queue type for exits. + let gossip_voluntary_exit_queue = FifoQueue::new(queue_lengths.gossip_voluntary_exit_queue); + + // Using a FIFO queue for slashing to prevent people from flushing their slashings from the + // queues with lots of junk messages. + let gossip_proposer_slashing_queue = + FifoQueue::new(queue_lengths.gossip_proposer_slashing_queue); + let gossip_attester_slashing_queue = + FifoQueue::new(queue_lengths.gossip_attester_slashing_queue); + + // Using a FIFO queue for light client updates to maintain sequence order. + let unknown_light_client_update_queue = + FifoQueue::new(queue_lengths.unknown_light_client_update_queue); + // Using a FIFO queue since blocks need to be imported sequentially. + let rpc_block_queue = FifoQueue::new(queue_lengths.rpc_block_queue); + let rpc_blob_queue = FifoQueue::new(queue_lengths.rpc_blob_queue); + let rpc_custody_column_queue = FifoQueue::new(queue_lengths.rpc_custody_column_queue); + let column_reconstruction_queue = LifoQueue::new(queue_lengths.column_reconstruction_queue); + let chain_segment_queue = FifoQueue::new(queue_lengths.chain_segment_queue); + let backfill_chain_segment = FifoQueue::new(queue_lengths.backfill_chain_segment); + let gossip_block_queue = FifoQueue::new(queue_lengths.gossip_block_queue); + let gossip_blob_queue = FifoQueue::new(queue_lengths.gossip_blob_queue); + let gossip_data_column_queue = FifoQueue::new(queue_lengths.gossip_data_column_queue); + let gossip_partial_data_column_queue = + FifoQueue::new(queue_lengths.gossip_partial_data_column_queue); + let delayed_block_queue = FifoQueue::new(queue_lengths.delayed_block_queue); + let delayed_envelope_queue = FifoQueue::new(queue_lengths.delayed_envelope_queue); + + let status_queue = FifoQueue::new(queue_lengths.status_queue); + let block_brange_queue = FifoQueue::new(queue_lengths.block_brange_queue); + let block_broots_queue = FifoQueue::new(queue_lengths.block_broots_queue); + let blob_broots_queue = FifoQueue::new(queue_lengths.blob_broots_queue); + let blob_brange_queue = FifoQueue::new(queue_lengths.blob_brange_queue); + let dcbroots_queue = FifoQueue::new(queue_lengths.dcbroots_queue); + let dcbrange_queue = FifoQueue::new(queue_lengths.dcbrange_queue); + let payload_envelopes_brange_queue = + FifoQueue::new(queue_lengths.payload_envelopes_brange_queue); + let payload_envelopes_broots_queue = + FifoQueue::new(queue_lengths.payload_envelopes_broots_queue); + + let gossip_bls_to_execution_change_queue = + FifoQueue::new(queue_lengths.gossip_bls_to_execution_change_queue); + + let gossip_execution_payload_queue = + FifoQueue::new(queue_lengths.gossip_execution_payload_queue); + let gossip_execution_payload_bid_queue = + FifoQueue::new(queue_lengths.gossip_execution_payload_bid_queue); + let gossip_payload_attestation_queue = + FifoQueue::new(queue_lengths.gossip_payload_attestation_queue); + let gossip_proposer_preferences_queue = + FifoQueue::new(queue_lengths.gossip_proposer_preferences_queue); + + let lc_gossip_optimistic_update_queue = + FifoQueue::new(queue_lengths.lc_gossip_optimistic_update_queue); + let lc_gossip_finality_update_queue = + FifoQueue::new(queue_lengths.lc_gossip_finality_update_queue); + let lc_bootstrap_queue = FifoQueue::new(queue_lengths.lc_bootstrap_queue); + let lc_rpc_optimistic_update_queue = + FifoQueue::new(queue_lengths.lc_rpc_optimistic_update_queue); + let lc_rpc_finality_update_queue = + FifoQueue::new(queue_lengths.lc_rpc_finality_update_queue); + let lc_update_range_queue: FifoQueue<Work<E>> = + FifoQueue::new(queue_lengths.lc_update_range_queue); + let gossip_inclusion_list_queue = + FifoQueue::new(queue_lengths.gossip_inclusion_list_queue); + + let api_request_p0_queue = FifoQueue::new(queue_lengths.api_request_p0_queue); + let api_request_p1_queue = FifoQueue::new(queue_lengths.api_request_p1_queue); + + WorkQueues { + aggregate_queue, + aggregate_debounce, + attestation_queue, + attestation_to_convert_queue, + attestation_debounce, + unknown_block_aggregate_queue, + unknown_block_attestation_queue, + sync_message_queue, + sync_contribution_queue, + gossip_voluntary_exit_queue, + gossip_proposer_slashing_queue, + gossip_attester_slashing_queue, + unknown_light_client_update_queue, + rpc_block_queue, + rpc_blob_queue, + rpc_custody_column_queue, + chain_segment_queue, + column_reconstruction_queue, + backfill_chain_segment, + gossip_block_queue, + gossip_blob_queue, + gossip_data_column_queue, + gossip_partial_data_column_queue, + delayed_block_queue, + delayed_envelope_queue, + status_queue, + block_brange_queue, + block_broots_queue, + blob_broots_queue, + blob_brange_queue, + dcbroots_queue, + dcbrange_queue, + payload_envelopes_brange_queue, + payload_envelopes_broots_queue, + gossip_bls_to_execution_change_queue, + gossip_execution_payload_queue, + gossip_execution_payload_bid_queue, + gossip_payload_attestation_queue, + gossip_proposer_preferences_queue, + lc_gossip_optimistic_update_queue, + lc_gossip_finality_update_queue, + lc_bootstrap_queue, + lc_rpc_optimistic_update_queue, + lc_rpc_finality_update_queue, + lc_update_range_queue, + gossip_inclusion_list_queue, + api_request_p0_queue, + api_request_p1_queue, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use types::{BeaconState, ChainSpec, Eth1Data, ForkName, MainnetEthSpec}; + + #[test] + fn min_queue_len() { + // State with no validators. + let spec = ForkName::latest().make_genesis_spec(ChainSpec::mainnet()); + let genesis_time = 0; + let state = BeaconState::<MainnetEthSpec>::new(genesis_time, Eth1Data::default(), &spec); + assert_eq!(state.validators().len(), 0); + let queue_lengths = BeaconProcessorQueueLengths::from_state(&state, &spec).unwrap(); + assert_eq!(queue_lengths.attestation_queue, MIN_QUEUE_LEN); + assert_eq!(queue_lengths.unknown_block_attestation_queue, MIN_QUEUE_LEN); + } +} diff --git a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs index c99388287c..38306b3bb6 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs @@ -35,6 +35,7 @@ use types::{EthSpec, Hash256, Slot}; const TASK_NAME: &str = "beacon_processor_reprocess_queue"; const GOSSIP_BLOCKS: &str = "gossip_blocks"; +const GOSSIP_ENVELOPES: &str = "gossip_envelopes"; const RPC_BLOCKS: &str = "rpc_blocks"; const ATTESTATIONS: &str = "attestations"; const ATTESTATIONS_PER_ROOT: &str = "attestations_per_root"; @@ -51,6 +52,10 @@ pub const QUEUED_ATTESTATION_DELAY: Duration = Duration::from_secs(12); /// For how long to queue light client updates for re-processing. pub const QUEUED_LIGHT_CLIENT_UPDATE_DELAY: Duration = Duration::from_secs(12); +/// Envelope timeout as a multiplier of slot duration. Envelopes waiting for their block will be +/// sent for processing after this many slots worth of time, even if the block hasn't arrived. +const QUEUED_ENVELOPE_DELAY_SLOTS: u32 = 1; + /// For how long to queue rpc blocks before sending them back for reprocessing. pub const QUEUED_RPC_BLOCK_DELAY: Duration = Duration::from_secs(4); @@ -65,6 +70,9 @@ pub const QUEUED_RECONSTRUCTION_DELAY: Duration = Duration::from_millis(150); /// it's nice to have extra protection. const MAXIMUM_QUEUED_BLOCKS: usize = 16; +/// Set an arbitrary upper-bound on the number of queued envelopes to avoid DoS attacks. +const MAXIMUM_QUEUED_ENVELOPES: usize = 16; + /// How many attestations we keep before new ones get dropped. const MAXIMUM_QUEUED_ATTESTATIONS: usize = 16_384; @@ -93,6 +101,8 @@ pub const RECONSTRUCTION_DEADLINE: (u64, u64) = (1, 4); pub enum ReprocessQueueMessage { /// A block that has been received early and we should queue for later processing. EarlyBlock(QueuedGossipBlock), + /// An execution payload envelope that references a block not yet in fork choice. + UnknownBlockForEnvelope(QueuedGossipEnvelope), /// A gossip block for hash `X` is being imported, we should queue the rpc block for the same /// hash until the gossip block is imported. RpcBlock(QueuedRpcBlock), @@ -120,6 +130,7 @@ pub enum ReprocessQueueMessage { /// Events sent by the scheduler once they are ready for re-processing. pub enum ReadyWork { Block(QueuedGossipBlock), + Envelope(QueuedGossipEnvelope), RpcBlock(QueuedRpcBlock), IgnoredRpcBlock(IgnoredRpcBlock), Unaggregate(QueuedUnaggregate), @@ -157,6 +168,13 @@ pub struct QueuedGossipBlock { pub process_fn: AsyncFn, } +/// An execution payload envelope that arrived early and has been queued for later import. +pub struct QueuedGossipEnvelope { + pub beacon_block_slot: Slot, + pub beacon_block_root: Hash256, + pub process_fn: AsyncFn, +} + /// A block that arrived for processing when the same block was being imported over gossip. /// It is queued for later import. pub struct QueuedRpcBlock { @@ -209,6 +227,8 @@ impl<E: EthSpec> From<QueuedBackfillBatch> for WorkEvent<E> { enum InboundEvent { /// A gossip block that was queued for later processing and is ready for import. ReadyGossipBlock(QueuedGossipBlock), + /// An envelope whose block has been imported and is now ready for processing. + ReadyEnvelope(Hash256), /// A rpc block that was queued because the same gossip block was being imported /// will now be retried for import. ReadyRpcBlock(QueuedRpcBlock), @@ -234,6 +254,8 @@ struct ReprocessQueue<S> { /* Queues */ /// Queue to manage scheduled early blocks. gossip_block_delay_queue: DelayQueue<QueuedGossipBlock>, + /// Queue to manage envelope timeouts (keyed by block root). + envelope_delay_queue: DelayQueue<Hash256>, /// Queue to manage scheduled early blocks. rpc_block_delay_queue: DelayQueue<QueuedRpcBlock>, /// Queue to manage scheduled attestations. @@ -246,6 +268,8 @@ struct ReprocessQueue<S> { /* Queued items */ /// Queued blocks. queued_gossip_block_roots: HashSet<Hash256>, + /// Queued envelopes awaiting their block, keyed by block root. + awaiting_envelopes_per_root: HashMap<Hash256, (QueuedGossipEnvelope, DelayKey)>, /// Queued aggregated attestations. queued_aggregates: FnvHashMap<usize, (QueuedAggregate, DelayKey)>, /// Queued attestations. @@ -266,6 +290,7 @@ struct ReprocessQueue<S> { next_attestation: usize, next_lc_update: usize, early_block_debounce: TimeLatch, + envelope_delay_debounce: TimeLatch, rpc_block_debounce: TimeLatch, attestation_delay_debounce: TimeLatch, lc_update_delay_debounce: TimeLatch, @@ -315,6 +340,13 @@ impl<S: SlotClock> Stream for ReprocessQueue<S> { Poll::Ready(None) | Poll::Pending => (), } + match self.envelope_delay_queue.poll_expired(cx) { + Poll::Ready(Some(block_root)) => { + return Poll::Ready(Some(InboundEvent::ReadyEnvelope(block_root.into_inner()))); + } + Poll::Ready(None) | Poll::Pending => (), + } + match self.rpc_block_delay_queue.poll_expired(cx) { Poll::Ready(Some(queued_block)) => { return Poll::Ready(Some(InboundEvent::ReadyRpcBlock(queued_block.into_inner()))); @@ -418,11 +450,13 @@ impl<S: SlotClock> ReprocessQueue<S> { work_reprocessing_rx, ready_work_tx, gossip_block_delay_queue: DelayQueue::new(), + envelope_delay_queue: DelayQueue::new(), rpc_block_delay_queue: DelayQueue::new(), attestations_delay_queue: DelayQueue::new(), lc_updates_delay_queue: DelayQueue::new(), column_reconstructions_delay_queue: DelayQueue::new(), queued_gossip_block_roots: HashSet::new(), + awaiting_envelopes_per_root: HashMap::new(), queued_lc_updates: FnvHashMap::default(), queued_aggregates: FnvHashMap::default(), queued_unaggregates: FnvHashMap::default(), @@ -433,6 +467,7 @@ impl<S: SlotClock> ReprocessQueue<S> { next_attestation: 0, next_lc_update: 0, early_block_debounce: TimeLatch::default(), + envelope_delay_debounce: TimeLatch::default(), rpc_block_debounce: TimeLatch::default(), attestation_delay_debounce: TimeLatch::default(), lc_update_delay_debounce: TimeLatch::default(), @@ -498,6 +533,52 @@ impl<S: SlotClock> ReprocessQueue<S> { } } } + // An envelope that references an unknown block. Queue it until the block is + // imported, or until the timeout expires. + InboundEvent::Msg(UnknownBlockForEnvelope(queued_envelope)) => { + let block_root = queued_envelope.beacon_block_root; + + // TODO(gloas): Perform lightweight pre-validation before queuing + // (e.g. verify builder signature) to prevent unsigned garbage from + // consuming queue slots. + + // Don't add the same envelope to the queue twice. This prevents DoS attacks. + if self.awaiting_envelopes_per_root.contains_key(&block_root) { + trace!( + ?block_root, + "Duplicate envelope for same block root, dropping" + ); + return; + } + + // When the queue is full, evict the oldest entry to make room for newer envelopes. + if self.awaiting_envelopes_per_root.len() >= MAXIMUM_QUEUED_ENVELOPES { + if self.envelope_delay_debounce.elapsed() { + warn!( + queue_size = MAXIMUM_QUEUED_ENVELOPES, + msg = "system resources may be saturated", + "Envelope delay queue is full, evicting oldest entry" + ); + } + if let Some(oldest_root) = + self.awaiting_envelopes_per_root.keys().next().copied() + && let Some((_envelope, delay_key)) = + self.awaiting_envelopes_per_root.remove(&oldest_root) + { + self.envelope_delay_queue.remove(&delay_key); + } + } + + // Register the timeout. + let delay_key = self.envelope_delay_queue.insert( + block_root, + self.slot_clock.slot_duration() * QUEUED_ENVELOPE_DELAY_SLOTS, + ); + + // Store the envelope keyed by block root. + self.awaiting_envelopes_per_root + .insert(block_root, (queued_envelope, delay_key)); + } // A rpc block arrived for processing at the same time when a gossip block // for the same block hash is being imported. We wait for `QUEUED_RPC_BLOCK_DELAY` // and then send the rpc block back for processing assuming the gossip import @@ -647,6 +728,23 @@ impl<S: SlotClock> ReprocessQueue<S> { block_root, parent_root, }) => { + // Unqueue the envelope we have for this root, if any. + if let Some((envelope, delay_key)) = + self.awaiting_envelopes_per_root.remove(&block_root) + { + self.envelope_delay_queue.remove(&delay_key); + if self + .ready_work_tx + .try_send(ReadyWork::Envelope(envelope)) + .is_err() + { + error!( + ?block_root, + "Failed to send envelope for reprocessing after block import" + ); + } + } + // Unqueue the attestations we have for this root, if any. if let Some(queued_ids) = self.awaiting_attestations_per_root.remove(&block_root) { let mut sent_count = 0; @@ -802,6 +900,25 @@ impl<S: SlotClock> ReprocessQueue<S> { error!("Failed to pop queued block"); } } + // An envelope's timeout has expired. Send it for processing regardless of + // whether the block has been imported. + InboundEvent::ReadyEnvelope(block_root) => { + if let Some((envelope, _delay_key)) = + self.awaiting_envelopes_per_root.remove(&block_root) + { + debug!( + ?block_root, + "Envelope timed out waiting for block, sending for processing" + ); + if self + .ready_work_tx + .try_send(ReadyWork::Envelope(envelope)) + .is_err() + { + error!(?block_root, "Failed to send envelope after timeout"); + } + } + } InboundEvent::ReadyAttestation(queued_id) => { metrics::inc_counter( &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_EXPIRED_ATTESTATIONS, @@ -941,6 +1058,11 @@ impl<S: SlotClock> ReprocessQueue<S> { &[GOSSIP_BLOCKS], self.gossip_block_delay_queue.len() as i64, ); + metrics::set_gauge_vec( + &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_TOTAL, + &[GOSSIP_ENVELOPES], + self.awaiting_envelopes_per_root.len() as i64, + ); metrics::set_gauge_vec( &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_TOTAL, &[RPC_BLOCKS], @@ -1339,4 +1461,163 @@ mod tests { assert_eq!(reconstruction.block_root, block_root); } } + + // Test that envelopes are properly cleaned up from `awaiting_envelopes_per_root` on timeout. + #[tokio::test] + async fn prune_awaiting_envelopes_per_root() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + // Insert an envelope. + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + + // Process the event to enter it into the delay queue. + queue.handle_message(InboundEvent::Msg(msg)); + + // Check that it is queued. + assert_eq!(queue.awaiting_envelopes_per_root.len(), 1); + assert!( + queue + .awaiting_envelopes_per_root + .contains_key(&beacon_block_root) + ); + + // Advance time to expire the envelope. + advance_time( + &queue.slot_clock, + queue.slot_clock.slot_duration() * QUEUED_ENVELOPE_DELAY_SLOTS * 2, + ) + .await; + let ready_msg = queue.next().await.unwrap(); + assert!(matches!(ready_msg, InboundEvent::ReadyEnvelope(_))); + queue.handle_message(ready_msg); + + // The entry for the block root should be gone. + assert!(queue.awaiting_envelopes_per_root.is_empty()); + } + + #[tokio::test] + async fn envelope_released_on_block_imported() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + let parent_root = Hash256::repeat_byte(0xab); + + // Insert an envelope. + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + + // Process the event to enter it into the delay queue. + queue.handle_message(InboundEvent::Msg(msg)); + + // Check that it is queued. + assert_eq!(queue.awaiting_envelopes_per_root.len(), 1); + + // Simulate block import. + let imported = ReprocessQueueMessage::BlockImported { + block_root: beacon_block_root, + parent_root, + }; + queue.handle_message(InboundEvent::Msg(imported)); + + // The entry for the block root should be gone. + assert!(queue.awaiting_envelopes_per_root.is_empty()); + // Delay queue entry should also be cancelled. + assert_eq!(queue.envelope_delay_queue.len(), 0); + } + + #[tokio::test] + async fn envelope_dedup_drops_second() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + // Insert an envelope. + let msg1 = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + let msg2 = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + + // Process both events. + queue.handle_message(InboundEvent::Msg(msg1)); + queue.handle_message(InboundEvent::Msg(msg2)); + + // Only one should be queued. + assert_eq!(queue.awaiting_envelopes_per_root.len(), 1); + assert_eq!(queue.envelope_delay_queue.len(), 1); + } + + #[tokio::test] + async fn envelope_capacity_evicts_oldest() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + // Fill the queue to capacity. + for i in 0..MAXIMUM_QUEUED_ENVELOPES { + let block_root = Hash256::repeat_byte(i as u8); + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root: block_root, + process_fn: Box::pin(async {}), + }); + queue.handle_message(InboundEvent::Msg(msg)); + } + assert_eq!( + queue.awaiting_envelopes_per_root.len(), + MAXIMUM_QUEUED_ENVELOPES + ); + + // One more should evict the oldest and insert the new one. + let overflow_root = Hash256::repeat_byte(0xff); + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root: overflow_root, + process_fn: Box::pin(async {}), + }); + queue.handle_message(InboundEvent::Msg(msg)); + + // Queue should still be at capacity, with the new root present. + assert_eq!( + queue.awaiting_envelopes_per_root.len(), + MAXIMUM_QUEUED_ENVELOPES + ); + assert!( + queue + .awaiting_envelopes_per_root + .contains_key(&overflow_root) + ); + } } diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index 4fc6b3a379..7dc0cbfc6d 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -2,7 +2,7 @@ use bls::PublicKeyBytes; use context_deserialize::ContextDeserialize; pub use eth2::Error; use eth2::types::beacon_response::EmptyMetadata; -use eth2::types::builder_bid::SignedBuilderBid; +use eth2::types::builder::SignedBuilderBid; use eth2::types::{ ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, SignedValidatorRegistrationData, Slot, @@ -10,10 +10,10 @@ use eth2::types::{ use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; use eth2::{ CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, - SSZ_CONTENT_TYPE_HEADER, StatusCode, ok_or_error, success_or_error, + SSZ_CONTENT_TYPE_HEADER, ok_or_error, success_or_error, }; use reqwest::header::{ACCEPT, HeaderMap, HeaderValue}; -use reqwest::{IntoUrl, Response}; +use reqwest::{IntoUrl, Response, StatusCode}; use sensitive_url::SensitiveUrl; use serde::Serialize; use serde::de::DeserializeOwned; @@ -542,7 +542,7 @@ mod tests { use super::*; use bls::Signature; use eth2::types::MainnetEthSpec; - use eth2::types::builder_bid::{BuilderBid, BuilderBidFulu}; + use eth2::types::builder::{BuilderBid, BuilderBidFulu}; use eth2::types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; use mockito::{Matcher, Server, ServerGuard}; diff --git a/beacon_node/client/Cargo.toml b/beacon_node/client/Cargo.toml index 3c4b2572c9..50d76e8f19 100644 --- a/beacon_node/client/Cargo.toml +++ b/beacon_node/client/Cargo.toml @@ -42,6 +42,6 @@ types = { workspace = true } [dev-dependencies] operation_pool = { workspace = true } -serde_yaml = { workspace = true } state_processing = { workspace = true } tokio = { workspace = true } +yaml_serde = { workspace = true } diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 694c6fb356..9dfb8304bc 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -43,7 +43,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use store::database::interface::BeaconNodeBackend; use timer::spawn_timer; use tracing::{debug, info, instrument, warn}; -use types::data_column_custody_group::compute_ordered_custody_column_indices; +use types::data::compute_ordered_custody_column_indices; use types::{ BeaconState, BlobSidecarList, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, SignedBeaconBlock, test_utils::generate_deterministic_keypairs, @@ -281,7 +281,7 @@ where validator_count, genesis_time, } => { - let execution_payload_header = generate_genesis_header(&spec, true); + let execution_payload_header = generate_genesis_header(&spec); let keypairs = generate_deterministic_keypairs(validator_count); let genesis_state = interop_genesis_state( &keypairs, @@ -315,7 +315,7 @@ where let deneb_time = genesis_time + (deneb_fork_epoch.as_u64() * E::slots_per_epoch() - * spec.seconds_per_slot); + * spec.get_slot_duration().as_secs()); // Shrink the blob availability window so users don't start // a sync right before blobs start to disappear from the P2P @@ -325,7 +325,7 @@ where .saturating_sub(BLOB_AVAILABILITY_REDUCTION_EPOCHS); let blob_availability_window = reduced_p2p_availability_epochs * E::slots_per_epoch() - * spec.seconds_per_slot; + * spec.get_slot_duration().as_secs(); if now > deneb_time + blob_availability_window { return Err( @@ -592,17 +592,17 @@ where .network_globals .clone() .ok_or("slot_notifier requires a libp2p network")?; - let seconds_per_slot = self + let slot_duration = self .chain_spec .as_ref() .ok_or("slot_notifier requires a chain spec")? - .seconds_per_slot; + .get_slot_duration(); spawn_notifier( context.executor, beacon_chain, network_globals, - seconds_per_slot, + slot_duration, ) .map_err(|e| format!("Unable to start slot notifier: {}", e))?; @@ -721,10 +721,9 @@ where if let Some(execution_layer) = beacon_chain.execution_layer.as_ref() { // Only send a head update *after* genesis. if let Ok(current_slot) = beacon_chain.slot() { - let params = beacon_chain - .canonical_head - .cached_head() - .forkchoice_update_parameters(); + let cached_head = beacon_chain.canonical_head.cached_head(); + let head_payload_status = cached_head.head_payload_status(); + let params = cached_head.forkchoice_update_parameters(); if params .head_hash .is_some_and(|hash| hash != ExecutionBlockHash::zero()) @@ -737,6 +736,7 @@ where .update_execution_engine_forkchoice( current_slot, params, + head_payload_status, Default::default(), ) .await; @@ -906,7 +906,7 @@ where let slot_clock = SystemTimeSlotClock::new( spec.genesis_slot, Duration::from_secs(genesis_time), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); self.slot_clock = Some(slot_clock); diff --git a/beacon_node/client/src/config.rs b/beacon_node/client/src/config.rs index aeaa196df8..851eb5da6c 100644 --- a/beacon_node/client/src/config.rs +++ b/beacon_node/client/src/config.rs @@ -236,7 +236,7 @@ mod tests { fn serde() { let config = Config::default(); let serialized = - serde_yaml::to_string(&config).expect("should serde encode default config"); - serde_yaml::from_str::<Config>(&serialized).expect("should serde decode default config"); + yaml_serde::to_string(&config).expect("should serde encode default config"); + yaml_serde::from_str::<Config>(&serialized).expect("should serde decode default config"); } } diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index 6236bcc3e1..8b00b591c5 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -1,16 +1,14 @@ use crate::metrics; use beacon_chain::{ BeaconChain, BeaconChainTypes, ExecutionStatus, - bellatrix_readiness::{ - BellatrixReadiness, GenesisExecutionPayloadStatus, MergeConfig, SECONDS_IN_A_WEEK, - }, + bellatrix_readiness::GenesisExecutionPayloadStatus, }; use execution_layer::{ EngineCapabilities, http::{ ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, ENGINE_GET_PAYLOAD_V2, - ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V2, - ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, + ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, }, }; use lighthouse_network::{NetworkGlobals, types::SyncState}; @@ -36,6 +34,7 @@ const SPEEDO_OBSERVATIONS: usize = 4; /// The number of slots between logs that give detail about backfill process. const BACKFILL_LOG_INTERVAL: u64 = 5; +const SECONDS_IN_A_WEEK: u64 = 604800; pub const FORK_READINESS_PREPARATION_SECONDS: u64 = SECONDS_IN_A_WEEK * 2; pub const ENGINE_CAPABILITIES_REFRESH_INTERVAL: u64 = 300; @@ -44,10 +43,8 @@ pub fn spawn_notifier<T: BeaconChainTypes>( executor: task_executor::TaskExecutor, beacon_chain: Arc<BeaconChain<T>>, network: Arc<NetworkGlobals<T::EthSpec>>, - seconds_per_slot: u64, + slot_duration: Duration, ) -> Result<(), String> { - let slot_duration = Duration::from_secs(seconds_per_slot); - let speedo = Mutex::new(Speedo::default()); // Keep track of sync state and reset the speedo on specific sync state changes. @@ -72,7 +69,6 @@ pub fn spawn_notifier<T: BeaconChainTypes>( wait_time = estimated_time_pretty(Some(next_slot.as_secs() as f64)), "Waiting for genesis" ); - bellatrix_readiness_logging(Slot::new(0), &beacon_chain).await; post_bellatrix_readiness_logging(Slot::new(0), &beacon_chain).await; genesis_execution_payload_logging(&beacon_chain).await; sleep(slot_duration).await; @@ -364,7 +360,7 @@ pub fn spawn_notifier<T: BeaconChainTypes>( let block_info = if current_slot > head_slot { " … empty".to_string() } else { - head_root.to_string() + head_root.short().to_string() }; let block_hash = match beacon_chain.canonical_head.head_execution_status() { @@ -378,7 +374,7 @@ pub fn spawn_notifier<T: BeaconChainTypes>( warn!( info = "chain not fully verified, \ block and attestation production disabled until execution engine syncs", - execution_block_hash = ?hash, + execution_block_hash = ?hash, "Head is optimistic" ); format!("{} (unverified)", hash) @@ -397,7 +393,7 @@ pub fn spawn_notifier<T: BeaconChainTypes>( info!( peers = peer_count_pretty(connected_peer_count), exec_hash = block_hash, - finalized_root = %finalized_checkpoint.root, + finalized_root = %finalized_checkpoint.root.short(), finalized_epoch = %finalized_checkpoint.epoch, epoch = %current_epoch, block = block_info, @@ -408,7 +404,7 @@ pub fn spawn_notifier<T: BeaconChainTypes>( metrics::set_gauge(&metrics::IS_SYNCED, 0); info!( peers = peer_count_pretty(connected_peer_count), - finalized_root = %finalized_checkpoint.root, + finalized_root = %finalized_checkpoint.root.short(), finalized_epoch = %finalized_checkpoint.epoch, %head_slot, %current_slot, @@ -416,7 +412,6 @@ pub fn spawn_notifier<T: BeaconChainTypes>( ); } - bellatrix_readiness_logging(current_slot, &beacon_chain).await; post_bellatrix_readiness_logging(current_slot, &beacon_chain).await; } }; @@ -427,78 +422,7 @@ pub fn spawn_notifier<T: BeaconChainTypes>( Ok(()) } -/// Provides some helpful logging to users to indicate if their node is ready for the Bellatrix -/// fork and subsequent merge transition. -async fn bellatrix_readiness_logging<T: BeaconChainTypes>( - current_slot: Slot, - beacon_chain: &BeaconChain<T>, -) { - let merge_completed = beacon_chain - .canonical_head - .cached_head() - .snapshot - .beacon_block - .message() - .body() - .execution_payload() - .is_ok_and(|payload| payload.parent_hash() != ExecutionBlockHash::zero()); - - let has_execution_layer = beacon_chain.execution_layer.is_some(); - - if merge_completed && has_execution_layer - || !beacon_chain.is_time_to_prepare_for_bellatrix(current_slot) - { - return; - } - - match beacon_chain.check_bellatrix_readiness(current_slot).await { - BellatrixReadiness::Ready { - config, - current_difficulty, - } => match config { - MergeConfig { - terminal_total_difficulty: Some(ttd), - terminal_block_hash: None, - terminal_block_hash_epoch: None, - } => { - info!( - terminal_total_difficulty = %ttd, - current_difficulty = current_difficulty - .map(|d| d.to_string()) - .unwrap_or_else(|| "??".into()), - "Ready for Bellatrix" - ) - } - MergeConfig { - terminal_total_difficulty: _, - terminal_block_hash: Some(terminal_block_hash), - terminal_block_hash_epoch: Some(terminal_block_hash_epoch), - } => { - info!( - info = "you are using override parameters, please ensure that you \ - understand these parameters and their implications.", - ?terminal_block_hash, - ?terminal_block_hash_epoch, - "Ready for Bellatrix" - ) - } - other => error!( - config = ?other, - "Inconsistent merge configuration" - ), - }, - readiness @ BellatrixReadiness::NotSynced => warn!( - info = %readiness, - "Not ready Bellatrix" - ), - readiness @ BellatrixReadiness::NoExecutionEndpoint => warn!( - info = %readiness, - "Not ready for Bellatrix" - ), - } -} - -/// Provides some helpful logging to users to indicate if their node is ready for Capella +/// Provides some helpful logging to users to indicate if their node is ready for upcoming forks async fn post_bellatrix_readiness_logging<T: BeaconChainTypes>( current_slot: Slot, beacon_chain: &BeaconChain<T>, @@ -568,8 +492,8 @@ fn find_next_fork_to_prepare<T: BeaconChainTypes>( // Find the first fork that is scheduled and close to happen if let Some(fork_epoch) = fork_epoch { let fork_slot = fork_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let preparation_slots = - FORK_READINESS_PREPARATION_SECONDS / beacon_chain.spec.seconds_per_slot; + let preparation_slots = FORK_READINESS_PREPARATION_SECONDS + / beacon_chain.spec.get_slot_duration().as_secs(); let in_fork_preparation_period = current_slot + preparation_slots > fork_slot; if in_fork_preparation_period { return Some(*fork); @@ -640,11 +564,11 @@ fn methods_required_for_fork( } } ForkName::Gloas => { - if !capabilities.get_payload_v5 { - missing_methods.push(ENGINE_GET_PAYLOAD_V5); + if !capabilities.get_payload_v6 { + missing_methods.push(ENGINE_GET_PAYLOAD_V6); } - if !capabilities.new_payload_v4 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V4); + if !capabilities.new_payload_v5 { + missing_methods.push(ENGINE_NEW_PAYLOAD_V5); } } } diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index c443e94574..a23ea948e4 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -13,7 +13,7 @@ arc-swap = "1.6.0" bls = { workspace = true } builder_client = { path = "../builder_client" } bytes = { workspace = true } -eth2 = { workspace = true, features = ["events", "lighthouse"] } +eth2 = { workspace = true, features = ["events", "lighthouse", "network"] } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } fixed_bytes = { workspace = true } diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 2dae0d8692..02ecb92a81 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -1,11 +1,12 @@ use crate::engines::ForkchoiceState; use crate::http::{ ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, - ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, ENGINE_GET_CLIENT_VERSION_V1, - ENGINE_GET_INCLUSION_LIST_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, - ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, - ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1, - ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, ENGINE_GET_BLOBS_V3, + ENGINE_GET_CLIENT_VERSION_V1, ENGINE_GET_INCLUSION_LIST_V1, + ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, + ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, + ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, + ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, }; use eth2::types::{ BlobsBundle, SsePayloadAttributes, SsePayloadAttributesV1, SsePayloadAttributesV2, @@ -79,7 +80,7 @@ impl From<reqwest::Error> for Error { e.status(), Some(StatusCode::UNAUTHORIZED) | Some(StatusCode::FORBIDDEN) ) { - Error::Auth(auth::Error::InvalidToken) + Error::Auth(auth::Error::InvalidToken(e.to_string())) } else { Error::HttpClient(e.into()) } @@ -158,7 +159,7 @@ impl ExecutionBlock { } #[superstruct( - variants(V1, V2, V3), + variants(V1, V2, V3, V4), variant_attributes(derive(Clone, Debug, Eq, Hash, PartialEq),), cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") @@ -171,10 +172,12 @@ pub struct PayloadAttributes { pub prev_randao: Hash256, #[superstruct(getter(copy))] pub suggested_fee_recipient: Address, - #[superstruct(only(V2, V3))] + #[superstruct(only(V2, V3, V4))] pub withdrawals: Vec<Withdrawal>, - #[superstruct(only(V3), partial_getter(copy))] + #[superstruct(only(V3, V4), partial_getter(copy))] pub parent_beacon_block_root: Hash256, + #[superstruct(only(V4), partial_getter(copy))] + pub slot_number: u64, } impl PayloadAttributes { @@ -184,24 +187,35 @@ impl PayloadAttributes { suggested_fee_recipient: Address, withdrawals: Option<Vec<Withdrawal>>, parent_beacon_block_root: Option<Hash256>, + slot_number: Option<u64>, ) -> Self { - match withdrawals { - Some(withdrawals) => match parent_beacon_block_root { - Some(parent_beacon_block_root) => PayloadAttributes::V3(PayloadAttributesV3 { + match (withdrawals, parent_beacon_block_root, slot_number) { + (Some(withdrawals), Some(parent_beacon_block_root), Some(slot_number)) => { + PayloadAttributes::V4(PayloadAttributesV4 { timestamp, prev_randao, suggested_fee_recipient, withdrawals, parent_beacon_block_root, - }), - None => PayloadAttributes::V2(PayloadAttributesV2 { + slot_number, + }) + } + (Some(withdrawals), Some(parent_beacon_block_root), None) => { + PayloadAttributes::V3(PayloadAttributesV3 { timestamp, prev_randao, suggested_fee_recipient, withdrawals, - }), - }, - None => PayloadAttributes::V1(PayloadAttributesV1 { + parent_beacon_block_root, + }) + } + (Some(withdrawals), None, _) => PayloadAttributes::V2(PayloadAttributesV2 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + }), + (None, _, _) => PayloadAttributes::V1(PayloadAttributesV1 { timestamp, prev_randao, suggested_fee_recipient, @@ -246,6 +260,21 @@ impl From<PayloadAttributes> for SsePayloadAttributes { withdrawals, parent_beacon_block_root, }), + // V4 maps to V3 for SSE (slot_number is not part of the SSE spec) + PayloadAttributes::V4(PayloadAttributesV4 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + parent_beacon_block_root, + slot_number: _, + }) => Self::V3(SsePayloadAttributesV3 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + parent_beacon_block_root, + }), } } } @@ -587,9 +616,11 @@ pub struct EngineCapabilities { pub new_payload_v2: bool, pub new_payload_v3: bool, pub new_payload_v4: bool, + pub new_payload_v5: bool, pub forkchoice_updated_v1: bool, pub forkchoice_updated_v2: bool, pub forkchoice_updated_v3: bool, + pub forkchoice_updated_v4: bool, pub get_payload_bodies_by_hash_v1: bool, pub get_payload_bodies_by_range_v1: bool, pub get_payload_v1: bool, @@ -597,10 +628,12 @@ pub struct EngineCapabilities { pub get_payload_v3: bool, pub get_payload_v4: bool, pub get_payload_v5: bool, + pub get_payload_v6: bool, pub get_client_version_v1: bool, pub get_blobs_v1: bool, pub get_blobs_v2: bool, pub get_inclusion_list_v1: bool, + pub get_blobs_v3: bool, } impl EngineCapabilities { @@ -618,6 +651,9 @@ impl EngineCapabilities { if self.new_payload_v4 { response.push(ENGINE_NEW_PAYLOAD_V4); } + if self.new_payload_v5 { + response.push(ENGINE_NEW_PAYLOAD_V5); + } if self.forkchoice_updated_v1 { response.push(ENGINE_FORKCHOICE_UPDATED_V1); } @@ -627,6 +663,9 @@ impl EngineCapabilities { if self.forkchoice_updated_v3 { response.push(ENGINE_FORKCHOICE_UPDATED_V3); } + if self.forkchoice_updated_v4 { + response.push(ENGINE_FORKCHOICE_UPDATED_V4); + } if self.get_payload_bodies_by_hash_v1 { response.push(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1); } @@ -648,6 +687,9 @@ impl EngineCapabilities { if self.get_payload_v5 { response.push(ENGINE_GET_PAYLOAD_V5); } + if self.get_payload_v6 { + response.push(ENGINE_GET_PAYLOAD_V6); + } if self.get_client_version_v1 { response.push(ENGINE_GET_CLIENT_VERSION_V1); } @@ -660,6 +702,9 @@ impl EngineCapabilities { if self.get_inclusion_list_v1 { response.push(ENGINE_GET_INCLUSION_LIST_V1); } + if self.get_blobs_v3 { + response.push(ENGINE_GET_BLOBS_V3); + } response } diff --git a/beacon_node/execution_layer/src/engine_api/auth.rs b/beacon_node/execution_layer/src/engine_api/auth.rs index af1ca195bd..3a27048b1a 100644 --- a/beacon_node/execution_layer/src/engine_api/auth.rs +++ b/beacon_node/execution_layer/src/engine_api/auth.rs @@ -14,7 +14,7 @@ pub const JWT_SECRET_LENGTH: usize = 32; #[derive(Debug)] pub enum Error { JWT(jsonwebtoken::errors::Error), - InvalidToken, + InvalidToken(String), InvalidKey(String), } diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index 6dabb0bb13..3c7f2618c3 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -35,6 +35,7 @@ pub const ENGINE_NEW_PAYLOAD_V1: &str = "engine_newPayloadV1"; pub const ENGINE_NEW_PAYLOAD_V2: &str = "engine_newPayloadV2"; pub const ENGINE_NEW_PAYLOAD_V3: &str = "engine_newPayloadV3"; pub const ENGINE_NEW_PAYLOAD_V4: &str = "engine_newPayloadV4"; +pub const ENGINE_NEW_PAYLOAD_V5: &str = "engine_newPayloadV5"; pub const ENGINE_NEW_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(8); pub const ENGINE_GET_PAYLOAD_V1: &str = "engine_getPayloadV1"; @@ -42,11 +43,13 @@ pub const ENGINE_GET_PAYLOAD_V2: &str = "engine_getPayloadV2"; pub const ENGINE_GET_PAYLOAD_V3: &str = "engine_getPayloadV3"; pub const ENGINE_GET_PAYLOAD_V4: &str = "engine_getPayloadV4"; pub const ENGINE_GET_PAYLOAD_V5: &str = "engine_getPayloadV5"; +pub const ENGINE_GET_PAYLOAD_V6: &str = "engine_getPayloadV6"; pub const ENGINE_GET_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(2); pub const ENGINE_FORKCHOICE_UPDATED_V1: &str = "engine_forkchoiceUpdatedV1"; pub const ENGINE_FORKCHOICE_UPDATED_V2: &str = "engine_forkchoiceUpdatedV2"; pub const ENGINE_FORKCHOICE_UPDATED_V3: &str = "engine_forkchoiceUpdatedV3"; +pub const ENGINE_FORKCHOICE_UPDATED_V4: &str = "engine_forkchoiceUpdatedV4"; pub const ENGINE_FORKCHOICE_UPDATED_TIMEOUT: Duration = Duration::from_secs(8); pub const ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1: &str = "engine_getPayloadBodiesByHashV1"; @@ -61,6 +64,7 @@ pub const ENGINE_GET_CLIENT_VERSION_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_GET_BLOBS_V1: &str = "engine_getBlobsV1"; pub const ENGINE_GET_BLOBS_V2: &str = "engine_getBlobsV2"; +pub const ENGINE_GET_BLOBS_V3: &str = "engine_getBlobsV3"; pub const ENGINE_GET_BLOBS_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_GET_INCLUSION_LIST_V1: &str = "engine_getInclusionListV1"; @@ -77,14 +81,17 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[ ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, + ENGINE_GET_PAYLOAD_V6, ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, + ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_CLIENT_VERSION_V1, @@ -757,6 +764,20 @@ impl HttpJsonRpc { .await } + pub async fn get_blobs_v3<E: EthSpec>( + &self, + versioned_hashes: Vec<Hash256>, + ) -> Result<Option<Vec<BlobAndProofV3<E>>>, Error> { + let params = json!([versioned_hashes]); + + self.rpc_request( + ENGINE_GET_BLOBS_V3, + params, + ENGINE_GET_BLOBS_TIMEOUT * self.execution_timeout_multiplier, + ) + .await + } + pub async fn get_block_by_number( &self, query: BlockByNumberQuery<'_>, @@ -944,7 +965,7 @@ impl HttpJsonRpc { Ok(response.into()) } - pub async fn new_payload_v4_gloas<E: EthSpec>( + pub async fn new_payload_v5_gloas<E: EthSpec>( &self, new_payload_request_gloas: NewPayloadRequestGloas<'_, E>, ) -> Result<PayloadStatusV1, Error> { @@ -964,7 +985,7 @@ impl HttpJsonRpc { let response: JsonPayloadStatusV1 = self .rpc_request( - ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V5, params, ENGINE_NEW_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier, ) @@ -1134,10 +1155,25 @@ impl HttpJsonRpc { .try_into() .map_err(Error::BadResponse) } + _ => Err(Error::UnsupportedForkVariant(format!( + "called get_payload_v5 with {}", + fork_name + ))), + } + } + + pub async fn get_payload_v6<E: EthSpec>( + &self, + fork_name: ForkName, + payload_id: PayloadId, + ) -> Result<GetPayloadResponse<E>, Error> { + let params = json!([JsonPayloadIdRequest::from(payload_id)]); + + match fork_name { ForkName::Gloas => { let response: JsonGetPayloadResponseGloas<E> = self .rpc_request( - ENGINE_GET_PAYLOAD_V5, + ENGINE_GET_PAYLOAD_V6, params, ENGINE_GET_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier, ) @@ -1147,7 +1183,7 @@ impl HttpJsonRpc { .map_err(Error::BadResponse) } _ => Err(Error::UnsupportedForkVariant(format!( - "called get_payload_v5 with {}", + "called get_payload_v6 with {}", fork_name ))), } @@ -1216,6 +1252,27 @@ impl HttpJsonRpc { Ok(response.into()) } + pub async fn forkchoice_updated_v4( + &self, + forkchoice_state: ForkchoiceState, + payload_attributes: Option<PayloadAttributes>, + ) -> Result<ForkchoiceUpdatedResponse, Error> { + let params = json!([ + JsonForkchoiceStateV1::from(forkchoice_state), + payload_attributes.map(JsonPayloadAttributes::from) + ]); + + let response: JsonForkchoiceUpdatedV1Response = self + .rpc_request( + ENGINE_FORKCHOICE_UPDATED_V4, + params, + ENGINE_FORKCHOICE_UPDATED_TIMEOUT * self.execution_timeout_multiplier, + ) + .await?; + + Ok(response.into()) + } + pub async fn get_payload_bodies_by_hash_v1<E: EthSpec>( &self, block_hashes: Vec<ExecutionBlockHash>, @@ -1284,9 +1341,11 @@ impl HttpJsonRpc { new_payload_v2: capabilities.contains(ENGINE_NEW_PAYLOAD_V2), new_payload_v3: capabilities.contains(ENGINE_NEW_PAYLOAD_V3), new_payload_v4: capabilities.contains(ENGINE_NEW_PAYLOAD_V4), + new_payload_v5: capabilities.contains(ENGINE_NEW_PAYLOAD_V5), forkchoice_updated_v1: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V1), forkchoice_updated_v2: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V2), forkchoice_updated_v3: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V3), + forkchoice_updated_v4: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V4), get_payload_bodies_by_hash_v1: capabilities .contains(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1), get_payload_bodies_by_range_v1: capabilities @@ -1296,10 +1355,12 @@ impl HttpJsonRpc { get_payload_v3: capabilities.contains(ENGINE_GET_PAYLOAD_V3), get_payload_v4: capabilities.contains(ENGINE_GET_PAYLOAD_V4), get_payload_v5: capabilities.contains(ENGINE_GET_PAYLOAD_V5), + get_payload_v6: capabilities.contains(ENGINE_GET_PAYLOAD_V6), get_client_version_v1: capabilities.contains(ENGINE_GET_CLIENT_VERSION_V1), get_blobs_v1: capabilities.contains(ENGINE_GET_BLOBS_V1), get_blobs_v2: capabilities.contains(ENGINE_GET_BLOBS_V2), get_inclusion_list_v1: capabilities.contains(ENGINE_GET_INCLUSION_LIST_V1), + get_blobs_v3: capabilities.contains(ENGINE_GET_BLOBS_V3), }) } @@ -1449,10 +1510,10 @@ impl HttpJsonRpc { } } NewPayloadRequest::Gloas(new_payload_request_gloas) => { - if engine_capabilities.new_payload_v4 { - self.new_payload_v4_gloas(new_payload_request_gloas).await + if engine_capabilities.new_payload_v5 { + self.new_payload_v5_gloas(new_payload_request_gloas).await } else { - Err(Error::RequiredMethodUnsupported("engine_newPayloadV4")) + Err(Error::RequiredMethodUnsupported("engine_newPayloadV5")) } } } @@ -1505,10 +1566,10 @@ impl HttpJsonRpc { } } ForkName::Gloas => { - if engine_capabilities.get_payload_v5 { - self.get_payload_v5(fork_name, payload_id).await + if engine_capabilities.get_payload_v6 { + self.get_payload_v6(fork_name, payload_id).await } else { - Err(Error::RequiredMethodUnsupported("engine_getPayloadv5")) + Err(Error::RequiredMethodUnsupported("engine_getPayloadV6")) } } ForkName::Base | ForkName::Altair => Err(Error::UnsupportedForkVariant(format!( @@ -1549,6 +1610,16 @@ impl HttpJsonRpc { )) } } + PayloadAttributes::V4(_) => { + if engine_capabilities.forkchoice_updated_v4 { + self.forkchoice_updated_v4(forkchoice_state, maybe_payload_attributes) + .await + } else { + Err(Error::RequiredMethodUnsupported( + "engine_forkchoiceUpdatedV4", + )) + } + } } } else if engine_capabilities.forkchoice_updated_v3 { self.forkchoice_updated_v3(forkchoice_state, maybe_payload_attributes) diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index cac5946ebe..9ab0c40e53 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -1,15 +1,13 @@ use super::*; use alloy_rlp::RlpEncodable; use serde::{Deserialize, Serialize}; -use ssz::{Decode, TryFromIter}; +use ssz::{Decode, Encode, TryFromIter}; use ssz_types::{FixedVector, VariableList, typenum::Unsigned}; use strum::EnumString; use superstruct::superstruct; -use types::beacon_block_body::KzgCommitments; -use types::blob_sidecar::BlobsList; -use types::execution_requests::{ - ConsolidationRequests, DepositRequests, RequestType, WithdrawalRequests, -}; +use types::data::BlobsList; +use types::execution::{ConsolidationRequests, DepositRequests, RequestType, WithdrawalRequests}; +use types::kzg_ext::KzgCommitments; use types::{Blob, KzgProof}; #[derive(Debug, PartialEq, Serialize, Deserialize)] @@ -109,6 +107,12 @@ pub struct JsonExecutionPayload<E: EthSpec> { #[superstruct(only(Deneb, Electra, Fulu, Eip7805, Gloas))] #[serde(with = "serde_utils::u64_hex_be")] pub excess_blob_gas: u64, + #[superstruct(only(Gloas))] + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub block_access_list: VariableList<u8, E::MaxBytesPerTransaction>, + #[superstruct(only(Gloas))] + #[serde(with = "serde_utils::u64_hex_be")] + pub slot_number: u64, } impl<E: EthSpec> From<ExecutionPayloadBellatrix<E>> for JsonExecutionPayloadBellatrix<E> { @@ -280,6 +284,8 @@ impl<E: EthSpec> TryFrom<ExecutionPayloadGloas<E>> for JsonExecutionPayloadGloas withdrawals: withdrawals_to_json(payload.withdrawals)?, blob_gas_used: payload.blob_gas_used, excess_blob_gas: payload.excess_blob_gas, + block_access_list: payload.block_access_list, + slot_number: payload.slot_number.into(), }) } } @@ -482,6 +488,8 @@ impl<E: EthSpec> TryFrom<JsonExecutionPayloadGloas<E>> for ExecutionPayloadGloas withdrawals: withdrawals_from_json(payload.withdrawals)?, blob_gas_used: payload.blob_gas_used, excess_blob_gas: payload.excess_blob_gas, + block_access_list: payload.block_access_list, + slot_number: payload.slot_number.into(), }) } } @@ -531,6 +539,34 @@ pub enum RequestsError { #[serde(transparent)] pub struct JsonExecutionRequests(pub Vec<String>); +impl<E: EthSpec> From<ExecutionRequests<E>> for JsonExecutionRequests { + fn from(requests: ExecutionRequests<E>) -> Self { + let mut result = Vec::new(); + if !requests.deposits.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Deposit.to_u8(), + hex::encode(requests.deposits.as_ssz_bytes()) + )); + } + if !requests.withdrawals.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Withdrawal.to_u8(), + hex::encode(requests.withdrawals.as_ssz_bytes()) + )); + } + if !requests.consolidations.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Consolidation.to_u8(), + hex::encode(requests.consolidations.as_ssz_bytes()) + )); + } + JsonExecutionRequests(result) + } +} + impl<E: EthSpec> TryFrom<JsonExecutionRequests> for ExecutionRequests<E> { type Error = RequestsError; @@ -791,7 +827,7 @@ impl<'a> From<&'a JsonWithdrawal> for EncodableJsonWithdrawal<'a> { } #[superstruct( - variants(V1, V2, V3), + variants(V1, V2, V3, V4), variant_attributes( derive(Debug, Clone, PartialEq, Serialize, Deserialize), serde(rename_all = "camelCase") @@ -807,10 +843,13 @@ pub struct JsonPayloadAttributes { pub prev_randao: Hash256, #[serde(with = "serde_utils::address_hex")] pub suggested_fee_recipient: Address, - #[superstruct(only(V2, V3))] + #[superstruct(only(V2, V3, V4))] pub withdrawals: Vec<JsonWithdrawal>, - #[superstruct(only(V3))] + #[superstruct(only(V3, V4))] pub parent_beacon_block_root: Hash256, + #[superstruct(only(V4))] + #[serde(with = "serde_utils::u64_hex_be")] + pub slot_number: u64, } impl From<PayloadAttributes> for JsonPayloadAttributes { @@ -834,6 +873,14 @@ impl From<PayloadAttributes> for JsonPayloadAttributes { withdrawals: pa.withdrawals.into_iter().map(Into::into).collect(), parent_beacon_block_root: pa.parent_beacon_block_root, }), + PayloadAttributes::V4(pa) => Self::V4(JsonPayloadAttributesV4 { + timestamp: pa.timestamp, + prev_randao: pa.prev_randao, + suggested_fee_recipient: pa.suggested_fee_recipient, + withdrawals: pa.withdrawals.into_iter().map(Into::into).collect(), + parent_beacon_block_root: pa.parent_beacon_block_root, + slot_number: pa.slot_number, + }), } } } @@ -859,6 +906,14 @@ impl From<JsonPayloadAttributes> for PayloadAttributes { withdrawals: jpa.withdrawals.into_iter().map(Into::into).collect(), parent_beacon_block_root: jpa.parent_beacon_block_root, }), + JsonPayloadAttributes::V4(jpa) => Self::V4(PayloadAttributesV4 { + timestamp: jpa.timestamp, + prev_randao: jpa.prev_randao, + suggested_fee_recipient: jpa.suggested_fee_recipient, + withdrawals: jpa.withdrawals.into_iter().map(Into::into).collect(), + parent_beacon_block_root: jpa.parent_beacon_block_root, + slot_number: jpa.slot_number, + }), } } } @@ -910,6 +965,9 @@ pub struct BlobAndProof<E: EthSpec> { pub proofs: KzgProofs<E>, } +/// A BlobAndProofV3 is just a BlobAndProofV2 that may also be `null` if unknown by the EL. +pub type BlobAndProofV3<E> = Option<BlobAndProofV2<E>>; + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JsonForkchoiceStateV1 { diff --git a/beacon_node/execution_layer/src/engines.rs b/beacon_node/execution_layer/src/engines.rs index cc2bfcc7b6..3e6f78abbe 100644 --- a/beacon_node/execution_layer/src/engines.rs +++ b/beacon_node/execution_layer/src/engines.rs @@ -15,7 +15,7 @@ use tokio::sync::{Mutex, RwLock, watch}; use tokio_stream::wrappers::WatchStream; use tracing::{debug, error, info, warn}; use types::ExecutionBlockHash; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; /// The number of payload IDs that will be stored for each `Engine`. /// diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 5a20be5dfe..a46ea4b7bb 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -4,7 +4,7 @@ //! This crate only provides useful functionality for "The Merge", it does not provide any of the //! deposit-contract functionality that the `beacon_node/eth1` crate already provides. -use crate::json_structures::{BlobAndProofV1, BlobAndProofV2}; +use crate::json_structures::{BlobAndProofV1, BlobAndProofV2, BlobAndProofV3}; use crate::payload_cache::PayloadCache; use arc_swap::ArcSwapOption; use auth::{Auth, JwtKey, strip_prefix}; @@ -18,11 +18,10 @@ pub use engine_api::{http, http::HttpJsonRpc, http::deposit_methods}; use engines::{Engine, EngineError}; pub use engines::{EngineState, ForkchoiceState}; use eth2::types::{BlobsBundle, FullPayloadContents}; -use eth2::types::{ForkVersionedResponse, builder_bid::SignedBuilderBid}; +use eth2::types::{ForkVersionedResponse, builder::SignedBuilderBid}; use fixed_bytes::UintExtended; use fork_choice::ForkchoiceUpdateParameters; use logging::crit; -use lru::LruCache; pub use payload_status::PayloadStatus; use payload_status::process_payload_status; use sensitive_url::SensitiveUrl; @@ -33,7 +32,6 @@ use std::collections::{HashMap, hash_map::Entry}; use std::fmt; use std::future::Future; use std::io::Write; -use std::num::NonZeroUsize; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -46,10 +44,10 @@ use tokio::{ use tokio_stream::wrappers::WatchStream; use tracing::{Instrument, debug, debug_span, error, info, instrument, warn}; use tree_hash::TreeHash; -use types::beacon_block_body::KzgCommitments; -use types::builder_bid::BuilderBid; -use types::non_zero_usize::new_non_zero_usize; -use types::payload::BlockProductionVersion; +use types::ExecutionPayloadGloas; +use types::builder::BuilderBid; +use types::execution::BlockProductionVersion; +use types::kzg_ext::KzgCommitments; use types::{ AbstractExecPayload, BlobsList, ExecutionPayloadDeneb, ExecutionRequests, KzgProofs, SignedBlindedBeaconBlock, @@ -76,10 +74,6 @@ pub const DEFAULT_EXECUTION_ENDPOINT: &str = "http://localhost:8551/"; /// Name for the default file used for the jwt secret. pub const DEFAULT_JWT_FILE: &str = "jwt.hex"; -/// Each time the `ExecutionLayer` retrieves a block from an execution node, it stores that block -/// in an LRU cache to avoid redundant lookups. This is the size of that cache. -const EXECUTION_BLOCKS_LRU_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(128); - /// A fee recipient address for use during block production. Only used as a very last resort if /// there is no address provided by the user. /// @@ -178,6 +172,7 @@ pub enum Error { VerifyingVersionedHashes(versioned_hashes::Error), HexError(hex::FromHexError), SszTypeError(ssz_types::Error), + Unexpected(String), } impl From<ssz_types::Error> for Error { @@ -220,6 +215,28 @@ pub enum BlockProposalContentsType<E: EthSpec> { Blinded(BlockProposalContents<E, BlindedPayload<E>>), } +pub struct BlockProposalContentsGloas<E: EthSpec> { + pub payload: ExecutionPayloadGloas<E>, + pub payload_value: Uint256, + pub blob_kzg_commitments: KzgCommitments<E>, + pub blobs_and_proofs: (BlobsList<E>, KzgProofs<E>), + pub execution_requests: ExecutionRequests<E>, + pub should_override_builder: bool, +} + +impl<E: EthSpec> From<GetPayloadResponseGloas<E>> for BlockProposalContentsGloas<E> { + fn from(response: GetPayloadResponseGloas<E>) -> Self { + Self { + payload: response.execution_payload, + payload_value: response.block_value, + blob_kzg_commitments: response.blobs_bundle.commitments, + blobs_and_proofs: (response.blobs_bundle.blobs, response.blobs_bundle.proofs), + execution_requests: response.requests, + should_override_builder: response.should_override_builder, + } + } +} + pub enum BlockProposalContents<E: EthSpec, Payload: AbstractExecPayload<E>> { Payload { payload: Payload, @@ -404,6 +421,7 @@ impl ProposerPreparationDataEntry { pub struct ProposerKey { slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, } #[derive(PartialEq, Clone)] @@ -447,7 +465,6 @@ struct Inner<E: EthSpec> { execution_engine_forkchoice_lock: Mutex<()>, suggested_fee_recipient: Option<Address>, proposer_preparation_data: Mutex<HashMap<u64, ProposerPreparationDataEntry>>, - execution_blocks: Mutex<LruCache<ExecutionBlockHash, ExecutionBlock>>, proposers: RwLock<HashMap<ProposerKey, Proposer>>, executor: TaskExecutor, payload_cache: PayloadCache<E>, @@ -558,7 +575,6 @@ impl<E: EthSpec> ExecutionLayer<E> { suggested_fee_recipient, proposer_preparation_data: Mutex::new(HashMap::new()), proposers: RwLock::new(HashMap::new()), - execution_blocks: Mutex::new(LruCache::new(EXECUTION_BLOCKS_LRU_CACHE_SIZE)), executor, payload_cache: PayloadCache::default(), last_new_payload_errored: RwLock::new(false), @@ -650,12 +666,6 @@ impl<E: EthSpec> ExecutionLayer<E> { .ok_or(ApiError::ExecutionHeadBlockNotFound)?; Ok(block.total_difficulty) } - /// Note: this function returns a mutex guard, be careful to avoid deadlocks. - async fn execution_blocks( - &self, - ) -> MutexGuard<'_, LruCache<ExecutionBlockHash, ExecutionBlock>> { - self.inner.execution_blocks.lock().await - } /// Gives access to a channel containing if the last engine state is online or not. /// @@ -900,6 +910,44 @@ impl<E: EthSpec> ExecutionLayer<E> { .and_then(|entry| entry.gas_limit) } + /// Maps to the `engine_getPayload` JSON-RPC call for post-Gloas payload construction. + /// + /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing + /// payload id for the given parameters. + /// + /// ## Fallback Behavior + /// + /// The result will be returned from the first node that returns successfully. No more nodes + /// will be contacted. + #[instrument(level = "debug", skip_all)] + pub async fn get_payload_gloas( + &self, + payload_parameters: PayloadParameters<'_>, + ) -> Result<BlockProposalContentsGloas<E>, Error> { + let payload_response_type = self.get_full_payload_caching(payload_parameters).await?; + let GetPayloadResponseType::Full(payload_response) = payload_response_type else { + return Err(Error::Unexpected( + "get_payload_gloas should never return a blinded payload".to_owned(), + )); + }; + let GetPayloadResponse::Gloas(payload_response) = payload_response else { + return Err(Error::Unexpected( + "get_payload_gloas should always return a gloas `GetPayloadResponse` variant" + .to_owned(), + )); + }; + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_GET_PAYLOAD_OUTCOME, + &[metrics::SUCCESS], + ); + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_GET_PAYLOAD_SOURCE, + &[metrics::LOCAL], + ); + + Ok(payload_response.into()) + } + /// Maps to the `engine_getPayload` JSON-RPC call. /// /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing @@ -1433,12 +1481,14 @@ impl<E: EthSpec> ExecutionLayer<E> { &self, slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, validator_index: u64, payload_attributes: PayloadAttributes, ) -> bool { let proposers_key = ProposerKey { slot, head_block_root, + head_payload_status, }; let existing = self.proposers().write().await.insert( @@ -1457,16 +1507,18 @@ impl<E: EthSpec> ExecutionLayer<E> { } /// If there has been a proposer registered via `Self::insert_proposer` with a matching `slot` - /// `head_block_root`, then return the appropriate `PayloadAttributes` for inclusion in - /// `forkchoiceUpdated` calls. + /// `head_block_root`, and `head_payload_status` then return the appropriate `PayloadAttributes` + /// for inclusion in `forkchoiceUpdated` calls. pub async fn payload_attributes( &self, current_slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, ) -> Option<PayloadAttributes> { let proposers_key = ProposerKey { slot: current_slot, head_block_root, + head_payload_status, }; let proposer = self.proposers().read().await.get(&proposers_key).cloned()?; @@ -1490,6 +1542,7 @@ impl<E: EthSpec> ExecutionLayer<E> { finalized_block_hash: ExecutionBlockHash, current_slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, ) -> Result<PayloadStatus, Error> { let _timer = metrics::start_timer_vec( &metrics::EXECUTION_LAYER_REQUEST_TIMES, @@ -1506,7 +1559,9 @@ impl<E: EthSpec> ExecutionLayer<E> { ); let next_slot = current_slot + 1; - let payload_attributes = self.payload_attributes(next_slot, head_block_root).await; + let payload_attributes = self + .payload_attributes(next_slot, head_block_root, head_payload_status) + .await; // Compute the "lookahead", the time between when the payload will be produced and now. if let Some(ref payload_attributes) = payload_attributes @@ -1599,208 +1654,6 @@ impl<E: EthSpec> ExecutionLayer<E> { Ok(versions) } - /// Used during block production to determine if the merge has been triggered. - /// - /// ## Specification - /// - /// `get_terminal_pow_block_hash` - /// - /// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md - pub async fn get_terminal_pow_block_hash( - &self, - spec: &ChainSpec, - timestamp: u64, - ) -> Result<Option<ExecutionBlockHash>, Error> { - let _timer = metrics::start_timer_vec( - &metrics::EXECUTION_LAYER_REQUEST_TIMES, - &[metrics::GET_TERMINAL_POW_BLOCK_HASH], - ); - - let hash_opt = self - .engine() - .request(|engine| async move { - let terminal_block_hash = spec.terminal_block_hash; - if terminal_block_hash != ExecutionBlockHash::zero() { - if self - .get_pow_block(engine, terminal_block_hash) - .await? - .is_some() - { - return Ok(Some(terminal_block_hash)); - } else { - return Ok(None); - } - } - - let block = self.get_pow_block_at_total_difficulty(engine, spec).await?; - if let Some(pow_block) = block { - // If `terminal_block.timestamp == transition_block.timestamp`, - // we violate the invariant that a block's timestamp must be - // strictly greater than its parent's timestamp. - // The execution layer will reject a fcu call with such payload - // attributes leading to a missed block. - // Hence, we return `None` in such a case. - if pow_block.timestamp >= timestamp { - return Ok(None); - } - } - Ok(block.map(|b| b.block_hash)) - }) - .await - .map_err(Box::new) - .map_err(Error::EngineError)?; - - if let Some(hash) = &hash_opt { - info!( - terminal_block_hash_override = ?spec.terminal_block_hash, - terminal_total_difficulty = ?spec.terminal_total_difficulty, - block_hash = ?hash, - "Found terminal block hash" - ); - } - - Ok(hash_opt) - } - - /// This function should remain internal. External users should use - /// `self.get_terminal_pow_block` instead, since it checks against the terminal block hash - /// override. - /// - /// ## Specification - /// - /// `get_pow_block_at_terminal_total_difficulty` - /// - /// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md - async fn get_pow_block_at_total_difficulty( - &self, - engine: &Engine, - spec: &ChainSpec, - ) -> Result<Option<ExecutionBlock>, ApiError> { - let mut block = engine - .api - .get_block_by_number(BlockByNumberQuery::Tag(LATEST_TAG)) - .await? - .ok_or(ApiError::ExecutionHeadBlockNotFound)?; - - self.execution_blocks().await.put(block.block_hash, block); - - loop { - let block_reached_ttd = - block.terminal_total_difficulty_reached(spec.terminal_total_difficulty); - if block_reached_ttd { - if block.parent_hash == ExecutionBlockHash::zero() { - return Ok(Some(block)); - } - let parent = self - .get_pow_block(engine, block.parent_hash) - .await? - .ok_or(ApiError::ExecutionBlockNotFound(block.parent_hash))?; - let parent_reached_ttd = - parent.terminal_total_difficulty_reached(spec.terminal_total_difficulty); - - if block_reached_ttd && !parent_reached_ttd { - return Ok(Some(block)); - } else { - block = parent; - } - } else { - return Ok(None); - } - } - } - - /// Used during block verification to check that a block correctly triggers the merge. - /// - /// ## Returns - /// - /// - `Some(true)` if the given `block_hash` is the terminal proof-of-work block. - /// - `Some(false)` if the given `block_hash` is certainly *not* the terminal proof-of-work - /// block. - /// - `None` if the `block_hash` or its parent were not present on the execution engine. - /// - `Err(_)` if there was an error connecting to the execution engine. - /// - /// ## Fallback Behaviour - /// - /// The request will be broadcast to all nodes, simultaneously. It will await a response (or - /// failure) from all nodes and then return based on the first of these conditions which - /// returns true: - /// - /// - Terminal, if any node indicates it is terminal. - /// - Not terminal, if any node indicates it is non-terminal. - /// - Block not found, if any node cannot find the block. - /// - An error, if all nodes return an error. - /// - /// ## Specification - /// - /// `is_valid_terminal_pow_block` - /// - /// https://github.com/ethereum/consensus-specs/blob/v1.1.0/specs/merge/fork-choice.md - pub async fn is_valid_terminal_pow_block_hash( - &self, - block_hash: ExecutionBlockHash, - spec: &ChainSpec, - ) -> Result<Option<bool>, Error> { - let _timer = metrics::start_timer_vec( - &metrics::EXECUTION_LAYER_REQUEST_TIMES, - &[metrics::IS_VALID_TERMINAL_POW_BLOCK_HASH], - ); - - self.engine() - .request(|engine| async move { - if let Some(pow_block) = self.get_pow_block(engine, block_hash).await? - && let Some(pow_parent) = - self.get_pow_block(engine, pow_block.parent_hash).await? - { - return Ok(Some( - self.is_valid_terminal_pow_block(pow_block, pow_parent, spec), - )); - } - Ok(None) - }) - .await - .map_err(Box::new) - .map_err(Error::EngineError) - } - - /// This function should remain internal. - /// - /// External users should use `self.is_valid_terminal_pow_block_hash`. - fn is_valid_terminal_pow_block( - &self, - block: ExecutionBlock, - parent: ExecutionBlock, - spec: &ChainSpec, - ) -> bool { - let is_total_difficulty_reached = - block.terminal_total_difficulty_reached(spec.terminal_total_difficulty); - let is_parent_total_difficulty_valid = parent - .total_difficulty - .is_some_and(|td| td < spec.terminal_total_difficulty); - is_total_difficulty_reached && is_parent_total_difficulty_valid - } - - /// Maps to the `eth_getBlockByHash` JSON-RPC call. - async fn get_pow_block( - &self, - engine: &Engine, - hash: ExecutionBlockHash, - ) -> Result<Option<ExecutionBlock>, ApiError> { - if let Some(cached) = self.execution_blocks().await.get(&hash).copied() { - // The block was in the cache, no need to request it from the execution - // engine. - return Ok(Some(cached)); - } - - // The block was *not* in the cache, request it from the execution - // engine and cache it for future reference. - if let Some(block) = engine.api.get_block_by_hash(hash).await? { - self.execution_blocks().await.put(hash, block); - Ok(Some(block)) - } else { - Ok(None) - } - } - pub async fn get_payload_bodies_by_hash( &self, hashes: Vec<ExecutionBlockHash>, @@ -1916,6 +1769,23 @@ impl<E: EthSpec> ExecutionLayer<E> { } } + pub async fn get_blobs_v3( + &self, + query: Vec<Hash256>, + ) -> Result<Option<Vec<BlobAndProofV3<E>>>, Error> { + let capabilities = self.get_engine_capabilities(None).await?; + + if capabilities.get_blobs_v3 { + self.engine() + .request(|engine| async move { engine.api.get_blobs_v3(query).await }) + .await + .map_err(Box::new) + .map_err(Error::EngineError) + } else { + Err(Error::GetBlobsNotSupported) + } + } + pub async fn get_block_by_number( &self, query: BlockByNumberQuery<'_>, @@ -2245,7 +2115,7 @@ fn verify_builder_bid<E: EthSpec>( .cloned() .map(|withdrawals| { Withdrawals::<E>::try_from(withdrawals) - .map_err(InvalidBuilderPayload::SszTypesError) + .map_err(|e| Box::new(InvalidBuilderPayload::SszTypesError(e))) .map(|w| w.tree_hash_root()) }) .transpose()?; @@ -2311,15 +2181,6 @@ async fn timed_future<F: Future<Output = T>, T>(metric: &str, future: F) -> (T, (result, duration) } -#[cfg(test)] -/// Returns the duration since the unix epoch. -fn timestamp_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() -} - fn noop<E: EthSpec>( _: &ExecutionLayer<E>, _: PayloadContentsRefTuple<E>, @@ -2340,7 +2201,6 @@ mod test { async fn produce_three_valid_pos_execution_blocks() { let runtime = TestRuntime::default(); MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() .produce_valid_execution_payload_on_head() .await .produce_valid_execution_payload_on_head() @@ -2369,129 +2229,4 @@ mod test { Some(30_029_266) ); } - - #[tokio::test] - async fn test_forked_terminal_block() { - let runtime = TestRuntime::default(); - let (mock, block_hash) = MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .produce_forked_pow_block(); - assert!( - mock.el - .is_valid_terminal_pow_block_hash(block_hash, &mock.spec) - .await - .unwrap() - .unwrap() - ); - } - - #[tokio::test] - async fn finds_valid_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_block_prior_to_terminal_block() - .with_terminal_block(|spec, el, _| async move { - el.engine().upcheck().await; - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp_now()) - .await - .unwrap(), - None - ) - }) - .await - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp_now()) - .await - .unwrap(), - Some(terminal_block.unwrap().block_hash) - ) - }) - .await; - } - - #[tokio::test] - async fn rejects_terminal_block_with_equal_timestamp() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_block_prior_to_terminal_block() - .with_terminal_block(|spec, el, _| async move { - el.engine().upcheck().await; - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp_now()) - .await - .unwrap(), - None - ) - }) - .await - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - let timestamp = terminal_block.as_ref().map(|b| b.timestamp).unwrap(); - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp) - .await - .unwrap(), - None - ) - }) - .await; - } - - #[tokio::test] - async fn verifies_valid_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - el.engine().upcheck().await; - assert_eq!( - el.is_valid_terminal_pow_block_hash(terminal_block.unwrap().block_hash, &spec) - .await - .unwrap(), - Some(true) - ) - }) - .await; - } - - #[tokio::test] - async fn rejects_invalid_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - el.engine().upcheck().await; - let invalid_terminal_block = terminal_block.unwrap().parent_hash; - - assert_eq!( - el.is_valid_terminal_pow_block_hash(invalid_terminal_block, &spec) - .await - .unwrap(), - Some(false) - ) - }) - .await; - } - - #[tokio::test] - async fn rejects_unknown_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .with_terminal_block(|spec, el, _| async move { - el.engine().upcheck().await; - let missing_terminal_block = ExecutionBlockHash::repeat_byte(42); - - assert_eq!( - el.is_valid_terminal_pow_block_hash(missing_terminal_block, &spec) - .await - .unwrap(), - None - ) - }) - .await; - } } diff --git a/beacon_node/execution_layer/src/metrics.rs b/beacon_node/execution_layer/src/metrics.rs index 859f33bc81..79bdc37aea 100644 --- a/beacon_node/execution_layer/src/metrics.rs +++ b/beacon_node/execution_layer/src/metrics.rs @@ -10,8 +10,6 @@ pub const GET_BLINDED_PAYLOAD_BUILDER: &str = "get_blinded_payload_builder"; pub const POST_BLINDED_PAYLOAD_BUILDER: &str = "post_blinded_payload_builder"; pub const NEW_PAYLOAD: &str = "new_payload"; pub const FORKCHOICE_UPDATED: &str = "forkchoice_updated"; -pub const GET_TERMINAL_POW_BLOCK_HASH: &str = "get_terminal_pow_block_hash"; -pub const IS_VALID_TERMINAL_POW_BLOCK_HASH: &str = "is_valid_terminal_pow_block_hash"; pub const LOCAL: &str = "local"; pub const BUILDER: &str = "builder"; pub const SUCCESS: &str = "success"; diff --git a/beacon_node/execution_layer/src/payload_cache.rs b/beacon_node/execution_layer/src/payload_cache.rs index 26ae89b5cb..ce65a53ef1 100644 --- a/beacon_node/execution_layer/src/payload_cache.rs +++ b/beacon_node/execution_layer/src/payload_cache.rs @@ -3,7 +3,7 @@ use lru::LruCache; use parking_lot::Mutex; use std::num::NonZeroUsize; use tree_hash::TreeHash; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; use types::{EthSpec, Hash256}; pub const DEFAULT_PAYLOAD_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(10); diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 3608cb7472..2e9da55439 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -1,7 +1,8 @@ use crate::engine_api::{ ExecutionBlock, PayloadAttributes, PayloadId, PayloadStatusV1, PayloadStatusV1Status, json_structures::{ - JsonForkchoiceUpdatedV1Response, JsonPayloadStatusV1, JsonPayloadStatusV1Status, + BlobAndProof, BlobAndProofV1, BlobAndProofV2, JsonForkchoiceUpdatedV1Response, + JsonPayloadStatusV1, JsonPayloadStatusV1Status, }, }; use crate::engines::ForkchoiceState; @@ -15,20 +16,20 @@ use rand::{Rng, SeedableRng, rngs::StdRng}; use serde::{Deserialize, Serialize}; use ssz::Decode; use ssz_types::VariableList; +use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; use std::cmp::max; use std::collections::HashMap; use std::sync::Arc; +use tracing::warn; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use types::{ Blob, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, ExecutionPayloadEip7805, ExecutionPayloadElectra, ExecutionPayloadFulu, ExecutionPayloadGloas, ExecutionPayloadHeader, - ForkName, Hash256, KzgProofs, Transaction, Transactions, Uint256, + ExecutionRequests, ForkName, Hash256, KzgProofs, Transaction, Transactions, Uint256, }; -use super::DEFAULT_TERMINAL_BLOCK; - const TEST_BLOB_BUNDLE: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle.ssz"); const TEST_BLOB_BUNDLE_V2: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle_v2.ssz"); @@ -161,6 +162,14 @@ pub struct ExecutionBlockGenerator<E: EthSpec> { pub blobs_bundles: HashMap<PayloadId, BlobsBundle<E>>, pub kzg: Option<Arc<Kzg>>, rng: Arc<Mutex<StdRng>>, + /* + * Execution requests (electra+) + */ + /// Per-payload execution requests returned by `getPayload`. + execution_requests: HashMap<PayloadId, ExecutionRequests<E>>, + /// If set, the next call to `build_new_execution_payload` will associate these + /// execution requests with the generated payload ID. + next_execution_requests: Option<ExecutionRequests<E>>, } fn make_rng() -> Arc<Mutex<StdRng>> { @@ -172,9 +181,6 @@ fn make_rng() -> Arc<Mutex<StdRng>> { impl<E: EthSpec> ExecutionBlockGenerator<E> { #[allow(clippy::too_many_arguments)] pub fn new( - terminal_total_difficulty: Uint256, - terminal_block_number: u64, - terminal_block_hash: ExecutionBlockHash, shanghai_time: Option<u64>, cancun_time: Option<u64>, prague_time: Option<u64>, @@ -188,9 +194,9 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { finalized_block_hash: <_>::default(), blocks: <_>::default(), block_hashes: <_>::default(), - terminal_total_difficulty, - terminal_block_number, - terminal_block_hash, + terminal_total_difficulty: Default::default(), + terminal_block_number: 0, + terminal_block_hash: Default::default(), pending_payloads: <_>::default(), next_payload_id: 0, payload_ids: <_>::default(), @@ -204,6 +210,8 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { blobs_bundles: <_>::default(), kzg, rng: make_rng(), + execution_requests: <_>::default(), + next_execution_requests: None, }; generator.insert_pow_block(0).unwrap(); @@ -296,25 +304,6 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { .and_then(|block| block.as_execution_payload()) } - pub fn move_to_block_prior_to_terminal_block(&mut self) -> Result<(), String> { - let target_block = self - .terminal_block_number - .checked_sub(1) - .ok_or("terminal pow block is 0")?; - self.move_to_pow_block(target_block) - } - - pub fn move_to_terminal_block(&mut self) -> Result<(), String> { - self.move_to_pow_block(self.terminal_block_number) - } - - pub fn move_to_pow_block(&mut self, target_block: u64) -> Result<(), String> { - let next_block = self.latest_block().unwrap().block_number() + 1; - assert!(target_block >= next_block); - - self.insert_pow_blocks(next_block..=target_block) - } - pub fn drop_all_blocks(&mut self) { self.blocks = <_>::default(); self.block_hashes = <_>::default(); @@ -483,6 +472,49 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { self.blobs_bundles.get(id).cloned() } + pub fn get_execution_requests(&self, id: &PayloadId) -> Option<ExecutionRequests<E>> { + self.execution_requests.get(id).cloned() + } + + /// Set execution requests to be returned alongside the next generated payload. + pub fn set_next_execution_requests(&mut self, requests: ExecutionRequests<E>) { + self.next_execution_requests = Some(requests); + } + + /// Look up a blob and proof by versioned hash across all stored bundles. + pub fn get_blob_and_proof(&self, versioned_hash: &Hash256) -> Option<BlobAndProof<E>> { + self.blobs_bundles + .iter() + .find_map(|(payload_id, blobs_bundle)| { + let (blob_idx, _) = + blobs_bundle + .commitments + .iter() + .enumerate() + .find(|(_, commitment)| { + &kzg_commitment_to_versioned_hash(commitment) == versioned_hash + })?; + let is_fulu = self.payload_ids.get(payload_id)?.fork_name().fulu_enabled(); + let blob = blobs_bundle.blobs.get(blob_idx)?.clone(); + if is_fulu { + let start = blob_idx * E::cells_per_ext_blob(); + let end = start + E::cells_per_ext_blob(); + let proofs = blobs_bundle + .proofs + .get(start..end)? + .to_vec() + .try_into() + .ok()?; + Some(BlobAndProof::V2(BlobAndProofV2 { blob, proofs })) + } else { + Some(BlobAndProof::V1(BlobAndProofV1 { + blob, + proof: *blobs_bundle.proofs.get(blob_idx)?, + })) + } + }) + } + pub fn new_payload(&mut self, payload: ExecutionPayload<E>) -> PayloadStatusV1 { let Some(parent) = self.blocks.get(&payload.parent_hash()) else { return PayloadStatusV1 { @@ -541,6 +573,21 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { .contains_key(&forkchoice_state.finalized_block_hash); if unknown_head_block_hash || unknown_safe_block_hash || unknown_finalized_block_hash { + if unknown_head_block_hash { + warn!(?head_block_hash, "Received unknown head block hash"); + } + if unknown_safe_block_hash { + warn!( + safe_block_hash = ?forkchoice_state.safe_block_hash, + "Received unknown safe block hash" + ); + } + if unknown_finalized_block_hash { + warn!( + finalized_block_hash = ?forkchoice_state.finalized_block_hash, + "Received unknown finalized block hash" + ) + } return Ok(JsonForkchoiceUpdatedV1Response { payload_status: JsonPayloadStatusV1 { status: JsonPayloadStatusV1Status::Syncing, @@ -730,6 +777,9 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { blob_gas_used: 0, excess_blob_gas: 0, }), + _ => unreachable!(), + }, + PayloadAttributes::V4(pa) => match self.get_fork_at_timestamp(pa.timestamp) { ForkName::Gloas => ExecutionPayload::Gloas(ExecutionPayloadGloas { parent_hash: head_block_hash, fee_recipient: pa.suggested_fee_recipient, @@ -748,11 +798,18 @@ impl<E: EthSpec> ExecutionBlockGenerator<E> { withdrawals: pa.withdrawals.clone().try_into().unwrap(), blob_gas_used: 0, excess_blob_gas: 0, + block_access_list: VariableList::empty(), + slot_number: pa.slot_number.into(), }), _ => unreachable!(), }, }; + // Store execution requests for this payload if configured. + if let Some(requests) = self.next_execution_requests.take() { + self.execution_requests.insert(id, requests); + } + let fork_name = execution_payload.fork_name(); if fork_name.deneb_enabled() { // get random number between 0 and 1 blobs by default @@ -886,27 +943,22 @@ fn payload_id_from_u64(n: u64) -> PayloadId { n.to_le_bytes() } -pub fn generate_genesis_header<E: EthSpec>( - spec: &ChainSpec, - post_transition_merge: bool, -) -> Option<ExecutionPayloadHeader<E>> { +pub fn generate_genesis_header<E: EthSpec>(spec: &ChainSpec) -> Option<ExecutionPayloadHeader<E>> { let genesis_fork = spec.fork_name_at_slot::<E>(spec.genesis_slot); - let genesis_block_hash = - generate_genesis_block(spec.terminal_total_difficulty, DEFAULT_TERMINAL_BLOCK) - .ok() - .map(|block| block.block_hash); + let genesis_block_hash = generate_genesis_block(Default::default(), 0) + .ok() + .map(|block| block.block_hash); let empty_transactions_root = Transactions::<E>::empty().tree_hash_root(); match genesis_fork { - ForkName::Base | ForkName::Altair => None, + ForkName::Base | ForkName::Altair => { + // Pre-Bellatrix forks have no execution payload + None + } ForkName::Bellatrix => { - if post_transition_merge { - let mut header = ExecutionPayloadHeader::Bellatrix(<_>::default()); - *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); - *header.transactions_root_mut() = empty_transactions_root; - Some(header) - } else { - Some(ExecutionPayloadHeader::<E>::Bellatrix(<_>::default())) - } + let mut header = ExecutionPayloadHeader::Bellatrix(<_>::default()); + *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); + *header.transactions_root_mut() = empty_transactions_root; + Some(header) } ForkName::Capella => { let mut header = ExecutionPayloadHeader::Capella(<_>::default()); @@ -938,8 +990,14 @@ pub fn generate_genesis_header<E: EthSpec>( *header.transactions_root_mut() = empty_transactions_root; Some(header) } - // TODO(EIP-7732): need to look into this - ForkName::Gloas => None, + ForkName::Gloas => { + // TODO(gloas): we are using a Fulu header for now, but this gets fixed up by the + // genesis builder anyway which translates it to bid/latest_block_hash. + let mut header = ExecutionPayloadHeader::Fulu(<_>::default()); + *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); + *header.transactions_root_mut() = empty_transactions_root; + Some(header) + } } } @@ -995,7 +1053,7 @@ pub fn generate_pow_block( #[cfg(test)] mod test { use super::*; - use kzg::{Bytes48, CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; + use kzg::{CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; use types::{MainnetEthSpec, MinimalEthSpec}; #[test] @@ -1086,10 +1144,11 @@ mod test { fn validate_blob_bundle_v1<E: EthSpec>() -> Result<(), String> { let kzg = load_kzg()?; let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle_v1::<E>()?; - let kzg_blob = kzg::Blob::from_bytes(blob.as_ref()) - .map(Box::new) - .map_err(|e| format!("Error converting blob to kzg blob: {e:?}"))?; - kzg.verify_blob_kzg_proof(&kzg_blob, kzg_commitment, kzg_proof) + let kzg_blob: KzgBlobRef = blob + .as_ref() + .try_into() + .map_err(|e| format!("Error converting blob to kzg blob ref: {e:?}"))?; + kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) .map_err(|e| format!("Invalid blobs bundle: {e:?}")) } @@ -1099,8 +1158,8 @@ mod test { load_test_blobs_bundle_v2::<E>().map(|(commitment, proofs, blob)| { let kzg_blob: KzgBlobRef = blob.as_ref().try_into().unwrap(); ( - vec![Bytes48::from(commitment); proofs.len()], - proofs.into_iter().map(|p| p.into()).collect::<Vec<_>>(), + vec![commitment.0; proofs.len()], + proofs.into_iter().map(|p| p.0).collect::<Vec<_>>(), kzg.compute_cells(kzg_blob).unwrap(), ) })?; diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 4eb71ae03f..816cec9760 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -5,6 +5,7 @@ use crate::test_utils::{DEFAULT_CLIENT_VERSION, DEFAULT_MOCK_EL_PAYLOAD_VALUE_WE use serde::{Deserialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; use std::sync::Arc; +use tracing::debug; use types::Transaction; pub const GENERIC_ERROR_CODE: i64 = -1234; @@ -29,6 +30,8 @@ pub async fn handle_rpc<E: EthSpec>( .ok_or_else(|| "missing/invalid params field".to_string()) .map_err(|s| (s, GENERIC_ERROR_CODE))?; + debug!(method, "Mock execution engine"); + match method { ETH_SYNCING => ctx .syncing_response @@ -100,7 +103,8 @@ pub async fn handle_rpc<E: EthSpec>( ENGINE_NEW_PAYLOAD_V1 | ENGINE_NEW_PAYLOAD_V2 | ENGINE_NEW_PAYLOAD_V3 - | ENGINE_NEW_PAYLOAD_V4 => { + | ENGINE_NEW_PAYLOAD_V4 + | ENGINE_NEW_PAYLOAD_V5 => { let request = match method { ENGINE_NEW_PAYLOAD_V1 => JsonExecutionPayload::Bellatrix( get_param::<JsonExecutionPayloadBellatrix<E>>(params, 0) @@ -116,21 +120,16 @@ pub async fn handle_rpc<E: EthSpec>( ENGINE_NEW_PAYLOAD_V3 => get_param::<JsonExecutionPayloadDeneb<E>>(params, 0) .map(|jep| JsonExecutionPayload::Deneb(jep)) .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, - ENGINE_NEW_PAYLOAD_V4 => get_param::<JsonExecutionPayloadGloas<E>>(params, 0) - .map(|jep| JsonExecutionPayload::Gloas(jep)) - .or_else(|_| { - get_param::<JsonExecutionPayloadFulu<E>>(params, 0) - .map(|jep| JsonExecutionPayload::Fulu(jep)) - }) + ENGINE_NEW_PAYLOAD_V4 => get_param::<JsonExecutionPayloadFulu<E>>(params, 0) + .map(|jep| JsonExecutionPayload::Fulu(jep)) .or_else(|_| { get_param::<JsonExecutionPayloadElectra<E>>(params, 0) .map(|jep| JsonExecutionPayload::Electra(jep)) }) .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, - // TODO(EIP7805) fix - // ENGINE_NEW_PAYLOAD_V5 => get_param::<JsonExecutionPayloadV5<E>>(params, 0) - // .map(|jep| JsonExecutionPayload::V5(jep)) - // .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, + ENGINE_NEW_PAYLOAD_V5 => get_param::<JsonExecutionPayloadGloas<E>>(params, 0) + .map(|jep| JsonExecutionPayload::Gloas(jep)) + .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, _ => unreachable!(), }; @@ -232,6 +231,14 @@ pub async fn handle_rpc<E: EthSpec>( )); } } + ForkName::Gloas => { + if method != ENGINE_NEW_PAYLOAD_V5 { + return Err(( + format!("{} called after Gloas fork!", method), + GENERIC_ERROR_CODE, + )); + } + } _ => unreachable!(), }; @@ -270,8 +277,9 @@ pub async fn handle_rpc<E: EthSpec>( ENGINE_GET_PAYLOAD_V1 | ENGINE_GET_PAYLOAD_V2 | ENGINE_GET_PAYLOAD_V3 + | ENGINE_GET_PAYLOAD_V4 | ENGINE_GET_PAYLOAD_V5 - | ENGINE_GET_PAYLOAD_V4 => { + | ENGINE_GET_PAYLOAD_V6 => { let request: JsonPayloadIdRequest = get_param(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; let id = request.into(); @@ -288,6 +296,10 @@ pub async fn handle_rpc<E: EthSpec>( })?; let maybe_blobs = ctx.execution_block_generator.write().get_blobs_bundle(&id); + let maybe_execution_requests = ctx + .execution_block_generator + .read() + .get_execution_requests(&id); // validate method called correctly according to shanghai fork time if ctx @@ -356,7 +368,8 @@ pub async fn handle_rpc<E: EthSpec>( && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2 || method == ENGINE_GET_PAYLOAD_V3 - || method == ENGINE_GET_PAYLOAD_V4) + || method == ENGINE_GET_PAYLOAD_V4 + || method == ENGINE_GET_PAYLOAD_V5) { return Err(( format!("{} called after Gloas fork!", method), @@ -423,8 +436,10 @@ pub async fn handle_rpc<E: EthSpec>( ))? .into(), should_override_builder: false, - // TODO(electra): add EL requests in mock el - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .clone() + .unwrap_or_default() + .into(), }) .unwrap() } @@ -444,7 +459,10 @@ pub async fn handle_rpc<E: EthSpec>( ))? .into(), should_override_builder: false, - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .clone() + .unwrap_or_default() + .into(), }) .unwrap() } @@ -463,18 +481,25 @@ pub async fn handle_rpc<E: EthSpec>( }) .unwrap() } + _ => unreachable!(), + }) + } + ENGINE_GET_PAYLOAD_V6 => { + Ok(match JsonExecutionPayload::try_from(response).unwrap() { JsonExecutionPayload::Gloas(execution_payload) => { serde_json::to_value(JsonGetPayloadResponseGloas { execution_payload, block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), blobs_bundle: maybe_blobs .ok_or(( - "No blobs returned despite V5 Payload".to_string(), + "No blobs returned despite V6 Payload".to_string(), GENERIC_ERROR_CODE, ))? .into(), should_override_builder: false, - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .unwrap_or_default() + .into(), }) .unwrap() } @@ -484,9 +509,39 @@ pub async fn handle_rpc<E: EthSpec>( _ => unreachable!(), } } + ENGINE_GET_BLOBS_V1 => { + let versioned_hashes = + get_param::<Vec<Hash256>>(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; + let generator = ctx.execution_block_generator.read(); + // V1: per-element nullable array, positionally matching the request. + let response: Vec<Option<BlobAndProofV1<E>>> = versioned_hashes + .iter() + .map(|hash| match generator.get_blob_and_proof(hash) { + Some(BlobAndProof::V1(v1)) => Some(v1), + _ => None, + }) + .collect(); + Ok(serde_json::to_value(response).unwrap()) + } + ENGINE_GET_BLOBS_V2 => { + let versioned_hashes = + get_param::<Vec<Hash256>>(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; + let generator = ctx.execution_block_generator.read(); + // V2: all-or-nothing — null if any blob is missing. + let results: Vec<Option<BlobAndProofV2<E>>> = versioned_hashes + .iter() + .map(|hash| match generator.get_blob_and_proof(hash) { + Some(BlobAndProof::V2(v2)) => Some(v2), + _ => None, + }) + .collect(); + let response: Option<Vec<BlobAndProofV2<E>>> = results.into_iter().collect(); + Ok(serde_json::to_value(response).unwrap()) + } ENGINE_FORKCHOICE_UPDATED_V1 | ENGINE_FORKCHOICE_UPDATED_V2 - | ENGINE_FORKCHOICE_UPDATED_V3 => { + | ENGINE_FORKCHOICE_UPDATED_V3 + | ENGINE_FORKCHOICE_UPDATED_V4 => { let forkchoice_state: JsonForkchoiceStateV1 = get_param(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; let payload_attributes = match method { @@ -533,9 +588,20 @@ pub async fn handle_rpc<E: EthSpec>( .map(|opt| opt.map(JsonPayloadAttributes::V3)) .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))? } + ENGINE_FORKCHOICE_UPDATED_V4 => { + get_param::<Option<JsonPayloadAttributesV4>>(params, 1) + .map(|opt| opt.map(JsonPayloadAttributes::V4)) + .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))? + } _ => unreachable!(), }; + debug!( + ?payload_attributes, + ?forkchoice_state, + "ENGINE_FORKCHOICE_UPDATED" + ); + // validate method called correctly according to fork time if let Some(pa) = payload_attributes.as_ref() { match ctx @@ -598,6 +664,14 @@ pub async fn handle_rpc<E: EthSpec>( )); } } + ForkName::Gloas => { + if method != ENGINE_FORKCHOICE_UPDATED_V4 { + return Err(( + format!("{} called after Gloas fork! Use V4.", method), + FORK_REQUEST_MISMATCH_ERROR_CODE, + )); + } + } _ => unreachable!(), }; } diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 53c484c474..72b26d68ea 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -29,7 +29,7 @@ use tokio_stream::StreamExt; use tracing::{debug, error, info, warn}; use tree_hash::TreeHash; use types::ExecutionBlockHash; -use types::builder_bid::{ +use types::builder::{ BuilderBid, BuilderBidBellatrix, BuilderBidCapella, BuilderBidDeneb, BuilderBidEip7805, BuilderBidElectra, BuilderBidFulu, SignedBuilderBid, }; @@ -266,7 +266,7 @@ impl<E: EthSpec> BidStuff<E> for BuilderBid<E> { } fn sign_builder_message(&mut self, sk: &SecretKey, spec: &ChainSpec) -> Signature { - let domain = spec.get_builder_domain(); + let domain = spec.get_builder_application_domain(); let message = self.signing_root(domain); sk.sign(message) } @@ -840,6 +840,10 @@ impl<E: EthSpec> MockBuilder<E> { let head_block_root = head_block_root.unwrap_or(head.canonical_root()); + // TODO(gloas): Currently the tests are pre-Gloas and we are not considering + // other payload statuses. This codepath may not be relevant for Gloas. + let head_payload_status = fork_choice::PayloadStatus::Pending; + let head_execution_payload = head .message() .body() @@ -900,7 +904,8 @@ impl<E: EthSpec> MockBuilder<E> { .data .genesis_time }; - let timestamp = (slots_since_genesis * self.spec.seconds_per_slot) + genesis_time; + let timestamp = + (slots_since_genesis * self.spec.get_slot_duration().as_secs()) + genesis_time; let head_state: BeaconState<E> = self .beacon_client @@ -937,17 +942,25 @@ impl<E: EthSpec> MockBuilder<E> { fee_recipient, expected_withdrawals, None, + None, ), - ForkName::Deneb - | ForkName::Electra - | ForkName::Fulu - | ForkName::Eip7805 - | ForkName::Gloas => PayloadAttributes::new( + ForkName::Deneb | ForkName::Electra | ForkName::Fulu | ForkName::Eip7805 => { + PayloadAttributes::new( + timestamp, + *prev_randao, + fee_recipient, + expected_withdrawals, + Some(head_block_root), + None, + ) + } + ForkName::Gloas => PayloadAttributes::new( timestamp, *prev_randao, fee_recipient, expected_withdrawals, Some(head_block_root), + Some(slot.as_u64()), ), ForkName::Base | ForkName::Altair => { return Err("invalid fork".to_string()); @@ -967,7 +980,13 @@ impl<E: EthSpec> MockBuilder<E> { ); self.el - .insert_proposer(slot, head_block_root, val_index, payload_attributes.clone()) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + val_index, + payload_attributes.clone(), + ) .await; let forkchoice_update_params = ForkchoiceUpdateParameters { @@ -985,6 +1004,7 @@ impl<E: EthSpec> MockBuilder<E> { finalized_execution_hash, slot - 1, head_block_root, + head_payload_status, ) .await .map_err(|e| format!("fcu call failed : {:?}", e))?; diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index 8fda95b97c..0007900f8c 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -1,9 +1,4 @@ -use crate::{ - test_utils::{ - DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, DEFAULT_TERMINAL_DIFFICULTY, MockServer, - }, - *, -}; +use crate::{test_utils::DEFAULT_JWT_SECRET, test_utils::MockServer, *}; use alloy_primitives::B256 as H256; use fixed_bytes::FixedBytesExtended; use kzg::Kzg; @@ -20,12 +15,10 @@ pub struct MockExecutionLayer<E: EthSpec> { impl<E: EthSpec> MockExecutionLayer<E> { pub fn default_params(executor: TaskExecutor) -> Self { let mut spec = MainnetEthSpec::default_spec(); - spec.terminal_total_difficulty = Uint256::from(DEFAULT_TERMINAL_DIFFICULTY); spec.terminal_block_hash = ExecutionBlockHash::zero(); spec.terminal_block_hash_activation_epoch = Epoch::new(0); Self::new( executor, - DEFAULT_TERMINAL_BLOCK, None, None, None, @@ -41,7 +34,6 @@ impl<E: EthSpec> MockExecutionLayer<E> { #[allow(clippy::too_many_arguments)] pub fn new( executor: TaskExecutor, - terminal_block: u64, shanghai_time: Option<u64>, cancun_time: Option<u64>, prague_time: Option<u64>, @@ -58,9 +50,6 @@ impl<E: EthSpec> MockExecutionLayer<E> { let server = MockServer::new( &handle, jwt_key, - spec.terminal_total_difficulty, - terminal_block, - spec.terminal_block_hash, shanghai_time, cancun_time, prague_time, @@ -104,20 +93,34 @@ impl<E: EthSpec> MockExecutionLayer<E> { let timestamp = block_number; let prev_randao = Hash256::from_low_u64_be(block_number); let head_block_root = Hash256::repeat_byte(42); + // TODO(gloas): allow statuses other than Pending? + let head_payload_status = fork_choice::PayloadStatus::Pending; let forkchoice_update_params = ForkchoiceUpdateParameters { head_root: head_block_root, head_hash: Some(parent_hash), justified_hash: None, finalized_hash: None, }; - let payload_attributes = - PayloadAttributes::new(timestamp, prev_randao, Address::repeat_byte(42), None, None); + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + Address::repeat_byte(42), + None, + None, + None, + ); // Insert a proposer to ensure the fork choice updated command works. let slot = Slot::new(0); let validator_index = 0; self.el - .insert_proposer(slot, head_block_root, validator_index, payload_attributes) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + validator_index, + payload_attributes, + ) .await; self.el @@ -127,6 +130,7 @@ impl<E: EthSpec> MockExecutionLayer<E> { ExecutionBlockHash::zero(), slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -138,8 +142,14 @@ impl<E: EthSpec> MockExecutionLayer<E> { chain_health: ChainHealth::Healthy, }; let suggested_fee_recipient = self.el.get_suggested_fee_recipient(validator_index).await; - let payload_attributes = - PayloadAttributes::new(timestamp, prev_randao, suggested_fee_recipient, None, None); + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + suggested_fee_recipient, + None, + None, + None, + ); let payload_parameters = PayloadParameters { parent_hash, @@ -185,8 +195,14 @@ impl<E: EthSpec> MockExecutionLayer<E> { chain_health: ChainHealth::Healthy, }; let suggested_fee_recipient = self.el.get_suggested_fee_recipient(validator_index).await; - let payload_attributes = - PayloadAttributes::new(timestamp, prev_randao, suggested_fee_recipient, None, None); + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + suggested_fee_recipient, + None, + None, + None, + ); let payload_parameters = PayloadParameters { parent_hash, @@ -276,6 +292,7 @@ impl<E: EthSpec> MockExecutionLayer<E> { // Use junk values for slot/head-root to ensure there is no payload supplied. let slot = Slot::new(0); let head_block_root = Hash256::repeat_byte(13); + // TODO(gloas): reconsider the state_payload_status self.el .notify_forkchoice_updated( block_hash, @@ -283,6 +300,7 @@ impl<E: EthSpec> MockExecutionLayer<E> { ExecutionBlockHash::zero(), slot, head_block_root, + fork_choice::PayloadStatus::Pending, ) .await .unwrap(); @@ -296,53 +314,4 @@ impl<E: EthSpec> MockExecutionLayer<E> { assert_eq!(head_execution_block.block_hash(), block_hash); assert_eq!(head_execution_block.parent_hash(), parent_hash); } - - pub fn move_to_block_prior_to_terminal_block(self) -> Self { - self.server - .execution_block_generator() - .move_to_block_prior_to_terminal_block() - .unwrap(); - self - } - - pub fn move_to_terminal_block(self) -> Self { - self.server - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - self - } - - pub fn produce_forked_pow_block(self) -> (Self, ExecutionBlockHash) { - let head_block = self - .server - .execution_block_generator() - .latest_block() - .unwrap(); - - let block_hash = self - .server - .execution_block_generator() - .insert_pow_block_by_hash(head_block.parent_hash(), 1) - .unwrap(); - (self, block_hash) - } - - pub async fn with_terminal_block<U, V>(self, func: U) -> Self - where - U: Fn(Arc<ChainSpec>, ExecutionLayer<E>, Option<ExecutionBlock>) -> V, - V: Future<Output = ()>, - { - let terminal_block_number = self - .server - .execution_block_generator() - .terminal_block_number; - let terminal_block = self - .server - .execution_block_generator() - .execution_block_by_number(terminal_block_number); - - func(self.spec.clone(), self.el.clone(), terminal_block).await; - self - } } diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index 7e036a13c2..5c2efb4689 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -35,8 +35,6 @@ pub use hook::Hook; pub use mock_builder::{MockBuilder, Operation, mock_builder_extra_data}; pub use mock_execution_layer::MockExecutionLayer; -pub const DEFAULT_TERMINAL_DIFFICULTY: u64 = 6400; -pub const DEFAULT_TERMINAL_BLOCK: u64 = 64; pub const DEFAULT_JWT_SECRET: [u8; 32] = [42; 32]; pub const DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI: u128 = 10_000_000_000_000_000; pub const DEFAULT_BUILDER_PAYLOAD_VALUE_WEI: u128 = 20_000_000_000_000_000; @@ -45,9 +43,11 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { new_payload_v2: true, new_payload_v3: true, new_payload_v4: true, + new_payload_v5: true, forkchoice_updated_v1: true, forkchoice_updated_v2: true, forkchoice_updated_v3: true, + forkchoice_updated_v4: true, get_payload_bodies_by_hash_v1: true, get_payload_bodies_by_range_v1: true, get_payload_v1: true, @@ -55,10 +55,12 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { get_payload_v3: true, get_payload_v4: true, get_payload_v5: true, + get_payload_v6: true, get_client_version_v1: true, get_blobs_v1: true, get_blobs_v2: true, get_inclusion_list_v1: true, + get_blobs_v3: true, }; pub static DEFAULT_CLIENT_VERSION: LazyLock<JsonClientVersionV1> = @@ -80,9 +82,6 @@ mod mock_execution_layer; pub struct MockExecutionConfig { pub server_config: Config, pub jwt_key: JwtKey, - pub terminal_difficulty: Uint256, - pub terminal_block: u64, - pub terminal_block_hash: ExecutionBlockHash, pub shanghai_time: Option<u64>, pub cancun_time: Option<u64>, pub prague_time: Option<u64>, @@ -95,9 +94,6 @@ impl Default for MockExecutionConfig { fn default() -> Self { Self { jwt_key: JwtKey::random(), - terminal_difficulty: Uint256::from(DEFAULT_TERMINAL_DIFFICULTY), - terminal_block: DEFAULT_TERMINAL_BLOCK, - terminal_block_hash: ExecutionBlockHash::zero(), server_config: Config::default(), shanghai_time: None, cancun_time: None, @@ -121,9 +117,6 @@ impl<E: EthSpec> MockServer<E> { Self::new( &runtime::Handle::current(), JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap(), - Uint256::from(DEFAULT_TERMINAL_DIFFICULTY), - DEFAULT_TERMINAL_BLOCK, - ExecutionBlockHash::zero(), None, // FIXME(capella): should this be the default? None, // FIXME(deneb): should this be the default? None, // FIXME(electra): should this be the default? @@ -142,9 +135,6 @@ impl<E: EthSpec> MockServer<E> { create_test_tracing_subscriber(); let MockExecutionConfig { jwt_key, - terminal_difficulty, - terminal_block, - terminal_block_hash, server_config, shanghai_time, cancun_time, @@ -156,9 +146,6 @@ impl<E: EthSpec> MockServer<E> { let last_echo_request = Arc::new(RwLock::new(None)); let preloaded_responses = Arc::new(Mutex::new(vec![])); let execution_block_generator = ExecutionBlockGenerator::new( - terminal_difficulty, - terminal_block, - terminal_block_hash, shanghai_time, cancun_time, prague_time, @@ -221,9 +208,6 @@ impl<E: EthSpec> MockServer<E> { pub fn new( handle: &runtime::Handle, jwt_key: JwtKey, - terminal_difficulty: Uint256, - terminal_block: u64, - terminal_block_hash: ExecutionBlockHash, shanghai_time: Option<u64>, cancun_time: Option<u64>, prague_time: Option<u64>, @@ -237,9 +221,6 @@ impl<E: EthSpec> MockServer<E> { MockExecutionConfig { server_config: Config::default(), jwt_key, - terminal_difficulty, - terminal_block, - terminal_block_hash, shanghai_time, cancun_time, prague_time, @@ -762,7 +743,7 @@ pub fn serve<E: EthSpec>( info!( listen_address = listening_socket.to_string(), - "Metrics HTTP server started" + "Mock execution client started" ); Ok((listening_socket, server)) diff --git a/beacon_node/http_api/Cargo.toml b/beacon_node/http_api/Cargo.toml index 571dab1027..dd15a76c7a 100644 --- a/beacon_node/http_api/Cargo.toml +++ b/beacon_node/http_api/Cargo.toml @@ -14,7 +14,7 @@ bytes = { workspace = true } context_deserialize = { workspace = true } directory = { workspace = true } either = { workspace = true } -eth2 = { workspace = true, features = ["lighthouse"] } +eth2 = { workspace = true, features = ["lighthouse", "network"] } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } execution_layer = { workspace = true } @@ -23,7 +23,6 @@ futures = { workspace = true } health_metrics = { workspace = true } hex = { workspace = true } lighthouse_network = { workspace = true } -lighthouse_tracing = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } lru = { workspace = true } @@ -34,6 +33,7 @@ operation_pool = { workspace = true } parking_lot = { workspace = true } proto_array = { workspace = true } rand = { workspace = true } +reqwest = { workspace = true } safe_arith = { workspace = true } sensitive_url = { workspace = true } serde = { workspace = true } diff --git a/beacon_node/http_api/src/attestation_performance.rs b/beacon_node/http_api/src/attestation_performance.rs deleted file mode 100644 index 6e285829d2..0000000000 --- a/beacon_node/http_api/src/attestation_performance.rs +++ /dev/null @@ -1,216 +0,0 @@ -use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use eth2::lighthouse::{ - AttestationPerformance, AttestationPerformanceQuery, AttestationPerformanceStatistics, -}; -use state_processing::{ - BlockReplayError, BlockReplayer, per_epoch_processing::EpochProcessingSummary, -}; -use std::sync::Arc; -use types::{BeaconState, BeaconStateError, EthSpec, Hash256}; -use warp_utils::reject::{custom_bad_request, custom_server_error, unhandled_error}; - -const MAX_REQUEST_RANGE_EPOCHS: usize = 100; -const BLOCK_ROOT_CHUNK_SIZE: usize = 100; - -#[derive(Debug)] -// We don't use the inner values directly, but they're used in the Debug impl. -enum AttestationPerformanceError { - BlockReplay(#[allow(dead_code)] BlockReplayError), - BeaconState(#[allow(dead_code)] BeaconStateError), - UnableToFindValidator(#[allow(dead_code)] usize), -} - -impl From<BlockReplayError> for AttestationPerformanceError { - fn from(e: BlockReplayError) -> Self { - Self::BlockReplay(e) - } -} - -impl From<BeaconStateError> for AttestationPerformanceError { - fn from(e: BeaconStateError) -> Self { - Self::BeaconState(e) - } -} - -pub fn get_attestation_performance<T: BeaconChainTypes>( - target: String, - query: AttestationPerformanceQuery, - chain: Arc<BeaconChain<T>>, -) -> Result<Vec<AttestationPerformance>, warp::Rejection> { - let spec = &chain.spec; - // We increment by 2 here so that when we build the state from the `prior_slot` it is - // still 1 epoch ahead of the first epoch we want to analyse. - // This ensures the `.is_previous_epoch_X` functions on `EpochProcessingSummary` return results - // for the correct epoch. - let start_epoch = query.start_epoch + 2; - let start_slot = start_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let prior_slot = start_slot - 1; - - let end_epoch = query.end_epoch + 2; - let end_slot = end_epoch.end_slot(T::EthSpec::slots_per_epoch()); - - // Ensure end_epoch is smaller than the current epoch - 1. - let current_epoch = chain.epoch().map_err(unhandled_error)?; - if query.end_epoch >= current_epoch - 1 { - return Err(custom_bad_request(format!( - "end_epoch must be less than the current epoch - 1. current: {}, end: {}", - current_epoch, query.end_epoch - ))); - } - - // Check query is valid. - if start_epoch > end_epoch { - return Err(custom_bad_request(format!( - "start_epoch must not be larger than end_epoch. start: {}, end: {}", - query.start_epoch, query.end_epoch - ))); - } - - // The response size can grow exceptionally large therefore we should check that the - // query is within permitted bounds to prevent potential OOM errors. - if (end_epoch - start_epoch).as_usize() > MAX_REQUEST_RANGE_EPOCHS { - return Err(custom_bad_request(format!( - "end_epoch must not exceed start_epoch by more than {} epochs. start: {}, end: {}", - MAX_REQUEST_RANGE_EPOCHS, query.start_epoch, query.end_epoch - ))); - } - - // Either use the global validator set, or the specified index. - // - // Does no further validation of the indices, so in the event an index has not yet been - // activated or does not yet exist (according to the head state), it will return all fields as - // `false`. - let index_range = if target.to_lowercase() == "global" { - chain - .with_head(|head| Ok((0..head.beacon_state.validators().len() as u64).collect())) - .map_err(unhandled_error::<BeaconChainError>)? - } else { - vec![target.parse::<u64>().map_err(|_| { - custom_bad_request(format!( - "Invalid validator index: {:?}", - target.to_lowercase() - )) - })?] - }; - - // Load block roots. - let mut block_roots: Vec<Hash256> = chain - .forwards_iter_block_roots_until(start_slot, end_slot) - .map_err(unhandled_error)? - .map(|res| res.map(|(root, _)| root)) - .collect::<Result<Vec<Hash256>, _>>() - .map_err(unhandled_error)?; - block_roots.dedup(); - - // Load first block so we can get its parent. - let first_block_root = block_roots.first().ok_or_else(|| { - custom_server_error( - "No blocks roots could be loaded. Ensure the beacon node is synced.".to_string(), - ) - })?; - let first_block = chain - .get_blinded_block(first_block_root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*first_block_root)) - }) - .map_err(unhandled_error)?; - - // Load the block of the prior slot which will be used to build the starting state. - let prior_block = chain - .get_blinded_block(&first_block.parent_root()) - .and_then(|maybe_block| { - maybe_block - .ok_or_else(|| BeaconChainError::MissingBeaconBlock(first_block.parent_root())) - }) - .map_err(unhandled_error)?; - - // Load state for block replay. - let state_root = prior_block.state_root(); - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let state = chain - .get_state(&state_root, Some(prior_slot), true) - .and_then(|maybe_state| maybe_state.ok_or(BeaconChainError::MissingBeaconState(state_root))) - .map_err(unhandled_error)?; - - // Allocate an AttestationPerformance vector for each validator in the range. - let mut perfs: Vec<AttestationPerformance> = - AttestationPerformance::initialize(index_range.clone()); - - let post_slot_hook = |state: &mut BeaconState<T::EthSpec>, - summary: Option<EpochProcessingSummary<T::EthSpec>>, - _is_skip_slot: bool| - -> Result<(), AttestationPerformanceError> { - // If a `summary` was not output then an epoch boundary was not crossed - // so we move onto the next slot. - if let Some(summary) = summary { - for (position, i) in index_range.iter().enumerate() { - let index = *i as usize; - - let val = perfs - .get_mut(position) - .ok_or(AttestationPerformanceError::UnableToFindValidator(index))?; - - // We are two epochs ahead since the summary is generated for - // `state.previous_epoch()` then `summary.is_previous_epoch_X` functions return - // data for the epoch before that. - let epoch = state.previous_epoch().as_u64() - 1; - - let is_active = summary.is_active_unslashed_in_previous_epoch(index); - - let received_source_reward = summary.is_previous_epoch_source_attester(index)?; - - let received_head_reward = summary.is_previous_epoch_head_attester(index)?; - - let received_target_reward = summary.is_previous_epoch_target_attester(index)?; - - let inclusion_delay = summary - .previous_epoch_inclusion_info(index) - .map(|info| info.delay); - - let perf = AttestationPerformanceStatistics { - active: is_active, - head: received_head_reward, - target: received_target_reward, - source: received_source_reward, - delay: inclusion_delay, - }; - - val.epochs.insert(epoch, perf); - } - } - Ok(()) - }; - - // Initialize block replayer - let mut replayer = BlockReplayer::new(state, spec) - .no_state_root_iter() - .no_signature_verification() - .minimal_block_root_verification() - .post_slot_hook(Box::new(post_slot_hook)); - - // Iterate through block roots in chunks to reduce load on memory. - for block_root_chunks in block_roots.chunks(BLOCK_ROOT_CHUNK_SIZE) { - // Load blocks from the block root chunks. - let blocks = block_root_chunks - .iter() - .map(|root| { - chain - .get_blinded_block(root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*root)) - }) - .map_err(unhandled_error) - }) - .collect::<Result<Vec<_>, _>>()?; - - replayer = replayer - .apply_blocks(blocks, None) - .map_err(|e| custom_server_error(format!("{:?}", e)))?; - } - - drop(replayer); - - Ok(perfs) -} diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs new file mode 100644 index 0000000000..06a5915c08 --- /dev/null +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -0,0 +1,335 @@ +use crate::block_id::BlockId; +use crate::publish_blocks::publish_column_sidecars; +use crate::task_spawner::{Priority, TaskSpawner}; +use crate::utils::{ChainFilter, EthV1Filter, NetworkTxFilter, ResponseFilter, TaskSpawnerFilter}; +use crate::version::{ + ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, + execution_optimistic_finalized_beacon_response, +}; +use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use bytes::Bytes; +use eth2::types as api_types; +use eth2::{CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use lighthouse_network::PubsubMessage; +use network::NetworkMessage; +use ssz::{Decode, Encode}; +use std::future::Future; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{debug, error, info, warn}; +use types::{EthSpec, SignedExecutionPayloadEnvelope}; +use warp::{ + Filter, Rejection, Reply, + hyper::{Body, Response}, +}; + +// POST beacon/execution_payload_envelope (SSZ) +pub(crate) fn post_beacon_execution_payload_envelope_ssz<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter<T>, + chain_filter: ChainFilter<T>, + network_tx_filter: NetworkTxFilter<T>, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("execution_payload_envelope")) + .and(warp::path::end()) + .and(warp::header::exact( + CONTENT_TYPE_HEADER, + SSZ_CONTENT_TYPE_HEADER, + )) + .and(warp::body::bytes()) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |body_bytes: Bytes, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + let envelope = + SignedExecutionPayloadEnvelope::<T::EthSpec>::from_ssz_bytes(&body_bytes) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })?; + publish_execution_payload_envelope(envelope, chain, &network_tx).await + }) + }, + ) + .boxed() +} + +// POST beacon/execution_payload_envelope +pub(crate) fn post_beacon_execution_payload_envelope<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter<T>, + chain_filter: ChainFilter<T>, + network_tx_filter: NetworkTxFilter<T>, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("execution_payload_envelope")) + .and(warp::path::end()) + .and(warp::body::json()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .and(network_tx_filter.clone()) + .then( + |envelope: SignedExecutionPayloadEnvelope<T::EthSpec>, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + publish_execution_payload_envelope(envelope, chain, &network_tx).await + }) + }, + ) + .boxed() +} +/// Publishes a signed execution payload envelope to the network. Implements +/// `POST /eth/v1/beacon/execution_payload_envelope` per the in-flight beacon-APIs PR +/// <https://github.com/ethereum/beacon-APIs/pull/580>. +pub async fn publish_execution_payload_envelope<T: BeaconChainTypes>( + envelope: SignedExecutionPayloadEnvelope<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + network_tx: &UnboundedSender<NetworkMessage<T::EthSpec>>, +) -> Result<Response<Body>, Rejection> { + let slot = envelope.slot(); + let beacon_block_root = envelope.message.beacon_block_root; + + // TODO(gloas): Replace this check once we have gossip validation. + if !chain.spec.is_gloas_scheduled() { + return Err(warp_utils::reject::custom_bad_request( + "Execution payload envelopes are not supported before the Gloas fork".into(), + )); + } + + // TODO(gloas): We should probably add validation here i.e. BroadcastValidation::Gossip + info!( + %slot, + %beacon_block_root, + builder_index = envelope.message.builder_index, + "Publishing signed execution payload envelope to network" + ); + + let blobs_and_proofs = chain.pending_payload_envelopes.write().take_blobs(slot); + + // Spawn the column-build task (CPU-bound KZG cell-and-proof computation) before + // publishing the envelope so it runs in parallel with envelope gossip, narrowing + // the window in which peers see envelope-without-columns. If envelope publication + // fails below, dropping this future drops the spawned `JoinHandle` (the running + // closure on the blocking pool finishes and is then discarded — no work cancellation). + let column_build_future = match blobs_and_proofs { + Some(blobs) if !blobs.is_empty() => Some(spawn_build_gloas_data_columns_task( + &chain, + beacon_block_root, + slot, + blobs, + )?), + _ => None, + }; + + // Publish the envelope to the network. + crate::utils::publish_pubsub_message( + network_tx, + PubsubMessage::ExecutionPayload(Box::new(envelope)), + ) + .map_err(|_| { + warn!(%slot, "Failed to publish execution payload envelope to network"); + warp_utils::reject::custom_server_error( + "Unable to publish execution payload envelope to network".into(), + ) + })?; + + // From here on the envelope is on the wire. `take_blobs` already consumed the cache + // entry, so a retry would not republish columns; returning Err would mislead the + // caller. Log column-build/publish failures and fall through to `Ok`. + if let Some(column_build_future) = column_build_future { + let gossip_verified_columns = match column_build_future.await { + Ok(columns) => columns, + Err(e) => { + error!( + %slot, + error = ?e, + "Failed to build data columns after envelope publication" + ); + return Ok(warp::reply().into_response()); + } + }; + + if !gossip_verified_columns.is_empty() { + if let Err(e) = publish_column_sidecars(network_tx, &gossip_verified_columns, &chain) { + error!( + %slot, + error = ?e, + "Failed to publish data column sidecars after envelope publication" + ); + return Ok(warp::reply().into_response()); + } + + let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let sampling_column_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_columns = gossip_verified_columns + .into_iter() + .filter(|col| sampling_column_indices.contains(&col.index())) + .collect::<Vec<_>>(); + + // Local processing only — envelope already broadcast, so log and fall through. + if !sampling_columns.is_empty() + && let Err(e) = + Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await + { + error!( + %slot, + error = ?e, + "Failed to process sampling data columns during envelope publication" + ); + } + } + } + + Ok(warp::reply().into_response()) +} + +fn spawn_build_gloas_data_columns_task<T: BeaconChainTypes>( + chain: &Arc<BeaconChain<T>>, + beacon_block_root: types::Hash256, + slot: types::Slot, + blobs: types::BlobsList<T::EthSpec>, +) -> Result<impl Future<Output = Result<Vec<GossipVerifiedDataColumn<T>>, Rejection>>, Rejection> { + let chain_for_build = chain.clone(); + let handle = chain + .task_executor + .spawn_blocking_handle( + move || build_gloas_data_columns(&chain_for_build, beacon_block_root, slot, &blobs), + "build_gloas_data_columns", + ) + .ok_or_else(|| warp_utils::reject::custom_server_error("runtime shutdown".to_string()))?; + + Ok(async move { + handle + .await + .map_err(|_| warp_utils::reject::custom_server_error("join error".to_string()))? + }) +} + +fn build_gloas_data_columns<T: BeaconChainTypes>( + chain: &BeaconChain<T>, + beacon_block_root: types::Hash256, + slot: types::Slot, + blobs: &types::BlobsList<T::EthSpec>, +) -> Result<Vec<GossipVerifiedDataColumn<T>>, Rejection> { + let blob_refs: Vec<_> = blobs.iter().collect(); + let data_column_sidecars = beacon_chain::kzg_utils::blobs_to_data_column_sidecars_gloas( + &blob_refs, + beacon_block_root, + slot, + &chain.kzg, + &chain.spec, + ) + .map_err(|e| { + error!( + error = ?e, + %slot, + "Failed to build data column sidecars for envelope" + ); + warp_utils::reject::custom_server_error(format!("{e:?}")) + })?; + + let gossip_verified_columns = data_column_sidecars + .into_iter() + .filter_map(|col| { + let index = *col.index(); + match GossipVerifiedDataColumn::new_for_block_publishing(col, chain) { + Ok(verified) => Some(verified), + Err(GossipDataColumnError::PriorKnownUnpublished) => None, + Err(e) => { + warn!( + %slot, + column_index = index, + error = ?e, + "Locally-built data column failed gossip verification" + ); + None + } + } + }) + .collect::<Vec<_>>(); + + debug!( + %slot, + column_count = gossip_verified_columns.len(), + "Built data columns for envelope publication" + ); + + Ok(gossip_verified_columns) +} + +// TODO(gloas): add tests for this endpoint once we support importing payloads into the db +// GET beacon/execution_payload_envelope/{block_id} +pub(crate) fn get_beacon_execution_payload_envelope<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + block_id_or_err: impl Filter<Extract = (BlockId,), Error = Rejection> + + Clone + + Send + + Sync + + 'static, + task_spawner_filter: TaskSpawnerFilter<T>, + chain_filter: ChainFilter<T>, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("execution_payload_envelope")) + .and(block_id_or_err) + .and(warp::path::end()) + .and(task_spawner_filter) + .and(chain_filter) + .and(warp::header::optional::<api_types::Accept>("accept")) + .then( + |block_id: BlockId, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + accept_header: Option<api_types::Accept>| { + task_spawner.blocking_response_task(Priority::P1, move || { + let (root, execution_optimistic, finalized) = block_id.root(&chain)?; + + let envelope = chain + .get_payload_envelope(&root) + .map_err(warp_utils::reject::unhandled_error)? + .ok_or_else(|| { + warp_utils::reject::custom_not_found(format!( + "execution payload envelope for block root {root}" + )) + })?; + + let fork_name = chain.spec.fork_name_at_slot::<T::EthSpec>(envelope.slot()); + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(envelope.as_ssz_bytes().into()) + .map(|res: Response<Body>| add_ssz_content_type_header(res)) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }), + _ => { + let res = execution_optimistic_finalized_beacon_response( + ResponseIncludesVersion::Yes(fork_name), + execution_optimistic, + finalized, + &envelope, + )?; + Ok(warp::reply::json(&res).into_response()) + } + } + .map(|resp| add_consensus_version_header(resp, fork_name)) + }) + }, + ) + .boxed() +} diff --git a/beacon_node/http_api/src/beacon/mod.rs b/beacon_node/http_api/src/beacon/mod.rs index df5e6eee5c..9ec1c476f6 100644 --- a/beacon_node/http_api/src/beacon/mod.rs +++ b/beacon_node/http_api/src/beacon/mod.rs @@ -1,2 +1,3 @@ +pub mod execution_payload_envelope; pub mod pool; pub mod states; diff --git a/beacon_node/http_api/src/beacon/pool.rs b/beacon_node/http_api/src/beacon/pool.rs index 059573c317..c6b8a69643 100644 --- a/beacon_node/http_api/src/beacon/pool.rs +++ b/beacon_node/http_api/src/beacon/pool.rs @@ -1,24 +1,31 @@ use crate::task_spawner::{Priority, TaskSpawner}; -use crate::utils::{NetworkTxFilter, OptionalConsensusVersionHeaderFilter, ResponseFilter}; +use crate::utils::{ + ChainFilter, EthV1Filter, NetworkTxFilter, OptionalConsensusVersionHeaderFilter, + ResponseFilter, TaskSpawnerFilter, +}; use crate::version::{ ResponseIncludesVersion, V1, V2, add_consensus_version_header, beacon_response, unsupported_version_rejection, }; use crate::{sync_committees, utils}; use beacon_chain::observed_operations::ObservationOutcome; +use beacon_chain::payload_attestation_verification::Error as PayloadAttestationError; use beacon_chain::{BeaconChain, BeaconChainTypes}; +use bytes::Bytes; use eth2::types::{AttestationPoolQuery, EndpointVersion, Failure, GenericResponse}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; use operation_pool::ReceivedPreCapella; use slot_clock::SlotClock; +use ssz::{Decode, Encode}; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use types::{ - Attestation, AttestationData, AttesterSlashing, ForkName, ProposerSlashing, - SignedBlsToExecutionChange, SignedVoluntaryExit, SingleAttestation, SyncCommitteeMessage, + Attestation, AttestationData, AttesterSlashing, ForkName, PayloadAttestationMessage, + ProposerSlashing, SignedBlsToExecutionChange, SignedVoluntaryExit, SingleAttestation, + SyncCommitteeMessage, }; use warp::filters::BoxedFilter; use warp::{Filter, Reply}; @@ -520,3 +527,137 @@ pub fn post_beacon_pool_attestations_v2<T: BeaconChainTypes>( ) .boxed() } + +/// POST beacon/pool/payload_attestations (JSON) +pub fn post_beacon_pool_payload_attestations<T: BeaconChainTypes>( + network_tx_filter: &NetworkTxFilter<T>, + optional_consensus_version_header_filter: OptionalConsensusVersionHeaderFilter, + beacon_pool_path: &BeaconPoolPathFilter<T>, +) -> ResponseFilter { + beacon_pool_path + .clone() + .and(warp::path("payload_attestations")) + .and(warp::path::end()) + .and(warp_utils::json::json()) + .and(optional_consensus_version_header_filter) + .and(network_tx_filter.clone()) + .then( + |task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + messages: Vec<PayloadAttestationMessage>, + _fork_name: Option<ForkName>, + network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| { + task_spawner.blocking_json_task(Priority::P0, move || { + publish_payload_attestation_messages(&chain, &network_tx, messages) + }) + }, + ) + .boxed() +} + +/// POST beacon/pool/payload_attestations (SSZ) +pub fn post_beacon_pool_payload_attestations_ssz<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter<T>, + chain_filter: ChainFilter<T>, + network_tx_filter: NetworkTxFilter<T>, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("pool")) + .and(warp::path("payload_attestations")) + .and(warp::path::end()) + .and(warp::body::bytes()) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |body_bytes: Bytes, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| { + task_spawner.blocking_json_task(Priority::P0, move || { + let item_len = <PayloadAttestationMessage as Encode>::ssz_fixed_len(); + if !body_bytes.len().is_multiple_of(item_len) { + return Err(warp_utils::reject::custom_bad_request(format!( + "SSZ body length {} is not a multiple of PayloadAttestationMessage size {}", + body_bytes.len(), + item_len, + ))); + } + let messages: Vec<PayloadAttestationMessage> = body_bytes + .chunks(item_len) + .map(|chunk| { + PayloadAttestationMessage::from_ssz_bytes(chunk).map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid SSZ: {e:?}" + )) + }) + }) + .collect::<Result<_, _>>()?; + + publish_payload_attestation_messages(&chain, &network_tx, messages) + }) + }, + ) + .boxed() +} + +fn publish_payload_attestation_messages<T: BeaconChainTypes>( + chain: &BeaconChain<T>, + network_tx: &UnboundedSender<NetworkMessage<T::EthSpec>>, + messages: Vec<PayloadAttestationMessage>, +) -> Result<(), warp::Rejection> { + let mut failures = vec![]; + let mut num_already_known = 0; + + for (index, message) in messages.into_iter().enumerate() { + match chain.verify_payload_attestation_message_for_gossip(message.clone()) { + Ok(verified) => { + utils::publish_pubsub_message( + network_tx, + PubsubMessage::PayloadAttestation(Box::new(message)), + )?; + + if let Err(e) = chain.apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) { + warn!( + error = ?e, + request_index = index, + "Payload attestation invalid for fork choice" + ); + } + } + Err(PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. }) => { + num_already_known += 1; + } + // TODO(gloas): requeue for reprocessing like attestations do. + Err(e) => { + error!( + error = ?e, + request_index = index, + "Failure verifying payload attestation for gossip" + ); + failures.push(Failure::new(index, format!("{e:?}"))); + } + } + } + + if num_already_known > 0 { + debug!( + count = num_already_known, + "Some payload attestations already known" + ); + } + + if failures.is_empty() { + Ok(()) + } else { + Err(warp_utils::reject::indexed_bad_request( + "error processing payload attestations".to_string(), + failures, + )) + } +} diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 6d06bcc77d..84ef3c1f26 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -3,19 +3,20 @@ use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::ResponseFilter; use crate::validator::pubkey_to_validator_index; use crate::version::{ - ResponseIncludesVersion, add_consensus_version_header, + ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, }; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::types::{ - ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, - ValidatorsRequestBody, + self as api_types, ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, + ValidatorIndexData, ValidatorsRequestBody, }; +use ssz::Encode; use std::sync::Arc; -use types::{ - AttestationShufflingId, CommitteeCache, Error as BeaconStateError, EthSpec, RelativeEpoch, -}; +use types::{AttestationShufflingId, BeaconStateError, CommitteeCache, EthSpec, RelativeEpoch}; use warp::filters::BoxedFilter; +use warp::http::Response; +use warp::hyper::Body; use warp::{Filter, Reply}; use warp_utils::query::multi_key_query; @@ -30,7 +31,6 @@ pub fn get_beacon_state_pending_consolidations<T: BeaconChainTypes>( beacon_states_path: BeaconStatesPath<T>, ) -> ResponseFilter { beacon_states_path - .clone() .and(warp::path("pending_consolidations")) .and(warp::path::end()) .then( @@ -163,6 +163,67 @@ pub fn get_beacon_state_pending_deposits<T: BeaconChainTypes>( .boxed() } +// GET beacon/states/{state_id}/proposer_lookahead +pub fn get_beacon_state_proposer_lookahead<T: BeaconChainTypes>( + beacon_states_path: BeaconStatesPath<T>, +) -> ResponseFilter { + beacon_states_path + .clone() + .and(warp::path("proposer_lookahead")) + .and(warp::path::end()) + .and(warp::header::optional::<api_types::Accept>("accept")) + .then( + |state_id: StateId, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>, + accept_header: Option<api_types::Accept>| { + task_spawner.blocking_response_task(Priority::P1, move || { + let (data, execution_optimistic, finalized, fork_name) = state_id + .map_state_and_execution_optimistic_and_finalized( + &chain, + |state, execution_optimistic, finalized| { + let Ok(lookahead) = state.proposer_lookahead() else { + return Err(warp_utils::reject::custom_bad_request( + "Proposer lookahead is not available for pre-Fulu states" + .to_string(), + )); + }; + + Ok(( + lookahead.to_vec(), + execution_optimistic, + finalized, + state.fork_name_unchecked(), + )) + }, + )?; + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(data.as_ssz_bytes().into()) + .map(|res: Response<Body>| add_ssz_content_type_header(res)) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }), + _ => execution_optimistic_finalized_beacon_response( + ResponseIncludesVersion::Yes(fork_name), + execution_optimistic, + finalized, + ValidatorIndexData(data), + ) + .map(|res| warp::reply::json(&res).into_response()), + } + .map(|resp| add_consensus_version_header(resp, fork_name)) + }) + }, + ) + .boxed() +} + // GET beacon/states/{state_id}/randao?epoch pub fn get_beacon_state_randao<T: BeaconChainTypes>( beacon_states_path: BeaconStatesPath<T>, diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index ea8b47f91e..e6b1ed0879 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -281,7 +281,9 @@ impl BlockId { warp_utils::reject::custom_not_found(format!("beacon block with root {}", root)) })?; - if !chain.spec.is_peer_das_enabled_for_epoch(block.epoch()) { + let fork_name = chain.spec.fork_name_at_epoch(block.epoch()); + + if !fork_name.fulu_enabled() { return Err(warp_utils::reject::custom_bad_request( "block is pre-Fulu and has no data columns".to_string(), )); @@ -290,12 +292,12 @@ impl BlockId { let data_column_sidecars = if let Some(indices) = query.indices { indices .iter() - .filter_map(|index| chain.get_data_column(&root, index).transpose()) + .filter_map(|index| chain.get_data_column(&root, index, fork_name).transpose()) .collect::<Result<DataColumnSidecarList<T::EthSpec>, _>>() .map_err(warp_utils::reject::unhandled_error)? } else { chain - .get_data_columns(&root) + .get_data_columns(&root, fork_name) .map_err(warp_utils::reject::unhandled_error)? .unwrap_or_default() }; @@ -462,17 +464,18 @@ impl BlockId { let num_found_column_keys = column_indices.len(); let num_required_columns = T::EthSpec::number_of_columns() / 2; let is_blob_available = num_found_column_keys >= num_required_columns; + let fork_name = chain.spec.fork_name_at_epoch(block.epoch()); if is_blob_available { let data_columns = column_indices .into_iter() - .filter_map( - |column_index| match chain.get_data_column(&root, &column_index) { + .filter_map(|column_index| { + match chain.get_data_column(&root, &column_index, fork_name) { Ok(Some(data_column)) => Some(Ok(data_column)), Ok(None) => None, Err(e) => Some(Err(warp_utils::reject::unhandled_error(e))), - }, - ) + } + }) .collect::<Result<Vec<_>, _>>()?; reconstruct_blobs(&chain.kzg, data_columns, blob_indices, block, &chain.spec).map_err( @@ -483,8 +486,9 @@ impl BlockId { }, ) } else { - Err(warp_utils::reject::custom_server_error(format!( - "Insufficient data columns to reconstruct blobs: required {num_required_columns}, but only {num_found_column_keys} were found." + Err(warp_utils::reject::custom_bad_request(format!( + "Insufficient data columns to reconstruct blobs: required {num_required_columns}, but only {num_found_column_keys} were found. \ + You may need to run the beacon node with --supernode or --semi-supernode." ))) } } diff --git a/beacon_node/http_api/src/block_packing_efficiency.rs b/beacon_node/http_api/src/block_packing_efficiency.rs deleted file mode 100644 index 3772470b28..0000000000 --- a/beacon_node/http_api/src/block_packing_efficiency.rs +++ /dev/null @@ -1,409 +0,0 @@ -use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use eth2::lighthouse::{ - BlockPackingEfficiency, BlockPackingEfficiencyQuery, ProposerInfo, UniqueAttestation, -}; -use parking_lot::Mutex; -use state_processing::{ - BlockReplayError, BlockReplayer, per_epoch_processing::EpochProcessingSummary, -}; -use std::collections::{HashMap, HashSet}; -use std::marker::PhantomData; -use std::sync::Arc; -use types::{ - AttestationRef, BeaconCommittee, BeaconState, BeaconStateError, BlindedPayload, ChainSpec, - Epoch, EthSpec, Hash256, OwnedBeaconCommittee, RelativeEpoch, SignedBeaconBlock, Slot, -}; -use warp_utils::reject::{custom_bad_request, custom_server_error, unhandled_error}; - -/// Load blocks from block roots in chunks to reduce load on memory. -const BLOCK_ROOT_CHUNK_SIZE: usize = 100; - -#[derive(Debug)] -// We don't use the inner values directly, but they're used in the Debug impl. -enum PackingEfficiencyError { - BlockReplay(#[allow(dead_code)] BlockReplayError), - BeaconState(#[allow(dead_code)] BeaconStateError), - CommitteeStoreError(#[allow(dead_code)] Slot), - InvalidAttestationError, -} - -impl From<BlockReplayError> for PackingEfficiencyError { - fn from(e: BlockReplayError) -> Self { - Self::BlockReplay(e) - } -} - -impl From<BeaconStateError> for PackingEfficiencyError { - fn from(e: BeaconStateError) -> Self { - Self::BeaconState(e) - } -} - -struct CommitteeStore { - current_epoch_committees: Vec<OwnedBeaconCommittee>, - previous_epoch_committees: Vec<OwnedBeaconCommittee>, -} - -impl CommitteeStore { - fn new() -> Self { - CommitteeStore { - current_epoch_committees: Vec::new(), - previous_epoch_committees: Vec::new(), - } - } -} - -struct PackingEfficiencyHandler<E: EthSpec> { - current_slot: Slot, - current_epoch: Epoch, - prior_skip_slots: u64, - available_attestations: HashSet<UniqueAttestation>, - included_attestations: HashMap<UniqueAttestation, u64>, - committee_store: CommitteeStore, - _phantom: PhantomData<E>, -} - -impl<E: EthSpec> PackingEfficiencyHandler<E> { - fn new( - start_epoch: Epoch, - starting_state: BeaconState<E>, - spec: &ChainSpec, - ) -> Result<Self, PackingEfficiencyError> { - let mut handler = PackingEfficiencyHandler { - current_slot: start_epoch.start_slot(E::slots_per_epoch()), - current_epoch: start_epoch, - prior_skip_slots: 0, - available_attestations: HashSet::new(), - included_attestations: HashMap::new(), - committee_store: CommitteeStore::new(), - _phantom: PhantomData, - }; - - handler.compute_epoch(start_epoch, &starting_state, spec)?; - Ok(handler) - } - - fn update_slot(&mut self, slot: Slot) { - self.current_slot = slot; - if slot % E::slots_per_epoch() == 0 { - self.current_epoch = Epoch::new(slot.as_u64() / E::slots_per_epoch()); - } - } - - fn prune_included_attestations(&mut self) { - let epoch = self.current_epoch; - self.included_attestations.retain(|x, _| { - x.slot >= Epoch::new(epoch.as_u64().saturating_sub(2)).start_slot(E::slots_per_epoch()) - }); - } - - fn prune_available_attestations(&mut self) { - let slot = self.current_slot; - self.available_attestations - .retain(|x| x.slot >= (slot.as_u64().saturating_sub(E::slots_per_epoch()))); - } - - fn apply_block( - &mut self, - block: &SignedBeaconBlock<E, BlindedPayload<E>>, - ) -> Result<usize, PackingEfficiencyError> { - let block_body = block.message().body(); - let attestations = block_body.attestations(); - - let mut attestations_in_block = HashMap::new(); - for attestation in attestations { - match attestation { - AttestationRef::Base(attn) => { - for (position, voted) in attn.aggregation_bits.iter().enumerate() { - if voted { - let unique_attestation = UniqueAttestation { - slot: attn.data.slot, - committee_index: attn.data.index, - committee_position: position, - }; - let inclusion_distance: u64 = block - .slot() - .as_u64() - .checked_sub(attn.data.slot.as_u64()) - .ok_or(PackingEfficiencyError::InvalidAttestationError)?; - - self.available_attestations.remove(&unique_attestation); - attestations_in_block.insert(unique_attestation, inclusion_distance); - } - } - } - AttestationRef::Electra(attn) => { - for (position, voted) in attn.aggregation_bits.iter().enumerate() { - if voted { - let unique_attestation = UniqueAttestation { - slot: attn.data.slot, - committee_index: attn.data.index, - committee_position: position, - }; - let inclusion_distance: u64 = block - .slot() - .as_u64() - .checked_sub(attn.data.slot.as_u64()) - .ok_or(PackingEfficiencyError::InvalidAttestationError)?; - - self.available_attestations.remove(&unique_attestation); - attestations_in_block.insert(unique_attestation, inclusion_distance); - } - } - } - } - } - - // Remove duplicate attestations as these yield no reward. - attestations_in_block.retain(|x, _| !self.included_attestations.contains_key(x)); - self.included_attestations - .extend(attestations_in_block.clone()); - - Ok(attestations_in_block.len()) - } - - fn add_attestations(&mut self, slot: Slot) -> Result<(), PackingEfficiencyError> { - let committees = self.get_committees_at_slot(slot)?; - for committee in committees { - for position in 0..committee.committee.len() { - let unique_attestation = UniqueAttestation { - slot, - committee_index: committee.index, - committee_position: position, - }; - self.available_attestations.insert(unique_attestation); - } - } - - Ok(()) - } - - fn compute_epoch( - &mut self, - epoch: Epoch, - state: &BeaconState<E>, - spec: &ChainSpec, - ) -> Result<(), PackingEfficiencyError> { - // Free some memory by pruning old attestations from the included set. - self.prune_included_attestations(); - - let new_committees = if state.committee_cache_is_initialized(RelativeEpoch::Current) { - state - .get_beacon_committees_at_epoch(RelativeEpoch::Current)? - .into_iter() - .map(BeaconCommittee::into_owned) - .collect::<Vec<_>>() - } else { - state - .initialize_committee_cache(epoch, spec)? - .get_all_beacon_committees()? - .into_iter() - .map(BeaconCommittee::into_owned) - .collect::<Vec<_>>() - }; - - self.committee_store - .previous_epoch_committees - .clone_from(&self.committee_store.current_epoch_committees); - - self.committee_store.current_epoch_committees = new_committees; - - Ok(()) - } - - fn get_committees_at_slot( - &self, - slot: Slot, - ) -> Result<Vec<OwnedBeaconCommittee>, PackingEfficiencyError> { - let mut committees = Vec::new(); - - for committee in &self.committee_store.current_epoch_committees { - if committee.slot == slot { - committees.push(committee.clone()); - } - } - for committee in &self.committee_store.previous_epoch_committees { - if committee.slot == slot { - committees.push(committee.clone()); - } - } - - if committees.is_empty() { - return Err(PackingEfficiencyError::CommitteeStoreError(slot)); - } - - Ok(committees) - } -} - -pub fn get_block_packing_efficiency<T: BeaconChainTypes>( - query: BlockPackingEfficiencyQuery, - chain: Arc<BeaconChain<T>>, -) -> Result<Vec<BlockPackingEfficiency>, warp::Rejection> { - let spec = &chain.spec; - - let start_epoch = query.start_epoch; - let start_slot = start_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let prior_slot = start_slot - 1; - - let end_epoch = query.end_epoch; - let end_slot = end_epoch.end_slot(T::EthSpec::slots_per_epoch()); - - // Check query is valid. - if start_epoch > end_epoch || start_epoch == 0 { - return Err(custom_bad_request(format!( - "invalid start and end epochs: {}, {}", - start_epoch, end_epoch - ))); - } - - let prior_epoch = start_epoch - 1; - let start_slot_of_prior_epoch = prior_epoch.start_slot(T::EthSpec::slots_per_epoch()); - - // Load block roots. - let mut block_roots: Vec<Hash256> = chain - .forwards_iter_block_roots_until(start_slot_of_prior_epoch, end_slot) - .map_err(unhandled_error)? - .collect::<Result<Vec<(Hash256, Slot)>, _>>() - .map_err(unhandled_error)? - .iter() - .map(|(root, _)| *root) - .collect(); - block_roots.dedup(); - - let first_block_root = block_roots - .first() - .ok_or_else(|| custom_server_error("no blocks were loaded".to_string()))?; - - let first_block = chain - .get_blinded_block(first_block_root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*first_block_root)) - }) - .map_err(unhandled_error)?; - - // Load state for block replay. - let starting_state_root = first_block.state_root(); - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let starting_state = chain - .get_state(&starting_state_root, Some(prior_slot), true) - .and_then(|maybe_state| { - maybe_state.ok_or(BeaconChainError::MissingBeaconState(starting_state_root)) - }) - .map_err(unhandled_error)?; - - // Initialize response vector. - let mut response = Vec::new(); - - // Initialize handler. - let handler = Arc::new(Mutex::new( - PackingEfficiencyHandler::new(prior_epoch, starting_state.clone(), spec) - .map_err(|e| custom_server_error(format!("{:?}", e)))?, - )); - - let pre_slot_hook = - |_, state: &mut BeaconState<T::EthSpec>| -> Result<(), PackingEfficiencyError> { - // Add attestations to `available_attestations`. - handler.lock().add_attestations(state.slot())?; - Ok(()) - }; - - let post_slot_hook = |state: &mut BeaconState<T::EthSpec>, - _summary: Option<EpochProcessingSummary<T::EthSpec>>, - is_skip_slot: bool| - -> Result<(), PackingEfficiencyError> { - handler.lock().update_slot(state.slot()); - - // Check if this a new epoch. - if state.slot() % T::EthSpec::slots_per_epoch() == 0 { - handler.lock().compute_epoch( - state.slot().epoch(T::EthSpec::slots_per_epoch()), - state, - spec, - )?; - } - - if is_skip_slot { - handler.lock().prior_skip_slots += 1; - } - - // Remove expired attestations. - handler.lock().prune_available_attestations(); - - Ok(()) - }; - - let pre_block_hook = |_state: &mut BeaconState<T::EthSpec>, - block: &SignedBeaconBlock<_, BlindedPayload<_>>| - -> Result<(), PackingEfficiencyError> { - let slot = block.slot(); - - let block_message = block.message(); - // Get block proposer info. - let proposer_info = ProposerInfo { - validator_index: block_message.proposer_index(), - graffiti: block_message.body().graffiti().as_utf8_lossy(), - }; - - // Store the count of available attestations at this point. - // In the future it may be desirable to check that the number of available attestations - // does not exceed the maximum possible amount given the length of available committees. - let available_count = handler.lock().available_attestations.len(); - - // Get all attestations included in the block. - let included = handler.lock().apply_block(block)?; - - let efficiency = BlockPackingEfficiency { - slot, - block_hash: block.canonical_root(), - proposer_info, - available_attestations: available_count, - included_attestations: included, - prior_skip_slots: handler.lock().prior_skip_slots, - }; - - // Write to response. - if slot >= start_slot { - response.push(efficiency); - } - - handler.lock().prior_skip_slots = 0; - - Ok(()) - }; - - // Build BlockReplayer. - let mut replayer = BlockReplayer::new(starting_state, spec) - .no_state_root_iter() - .no_signature_verification() - .minimal_block_root_verification() - .pre_slot_hook(Box::new(pre_slot_hook)) - .post_slot_hook(Box::new(post_slot_hook)) - .pre_block_hook(Box::new(pre_block_hook)); - - // Iterate through the block roots, loading blocks in chunks to reduce load on memory. - for block_root_chunks in block_roots.chunks(BLOCK_ROOT_CHUNK_SIZE) { - // Load blocks from the block root chunks. - let blocks = block_root_chunks - .iter() - .map(|root| { - chain - .get_blinded_block(root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*root)) - }) - .map_err(unhandled_error) - }) - .collect::<Result<Vec<_>, _>>()?; - - replayer = replayer - .apply_blocks(blocks, None) - .map_err(|e: PackingEfficiencyError| custom_server_error(format!("{:?}", e)))?; - } - - drop(replayer); - - Ok(response) -} diff --git a/beacon_node/http_api/src/block_rewards.rs b/beacon_node/http_api/src/block_rewards.rs deleted file mode 100644 index 29b23e89a7..0000000000 --- a/beacon_node/http_api/src/block_rewards.rs +++ /dev/null @@ -1,178 +0,0 @@ -use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; -use eth2::lighthouse::{BlockReward, BlockRewardsQuery}; -use lru::LruCache; -use state_processing::BlockReplayer; -use std::num::NonZeroUsize; -use std::sync::Arc; -use tracing::{debug, warn}; -use types::beacon_block::BlindedBeaconBlock; -use types::non_zero_usize::new_non_zero_usize; -use warp_utils::reject::{beacon_state_error, custom_bad_request, unhandled_error}; - -const STATE_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(2); - -/// Fetch block rewards for blocks from the canonical chain. -pub fn get_block_rewards<T: BeaconChainTypes>( - query: BlockRewardsQuery, - chain: Arc<BeaconChain<T>>, -) -> Result<Vec<BlockReward>, warp::Rejection> { - let start_slot = query.start_slot; - let end_slot = query.end_slot; - let prior_slot = start_slot - 1; - - if start_slot > end_slot || start_slot == 0 { - return Err(custom_bad_request(format!( - "invalid start and end: {}, {}", - start_slot, end_slot - ))); - } - - let end_block_root = chain - .block_root_at_slot(end_slot, WhenSlotSkipped::Prev) - .map_err(unhandled_error)? - .ok_or_else(|| custom_bad_request(format!("block at end slot {} unknown", end_slot)))?; - - let blocks = chain - .store - .load_blocks_to_replay(start_slot, end_slot, end_block_root) - .map_err(|e| unhandled_error(BeaconChainError::from(e)))?; - - let state_root = chain - .state_root_at_slot(prior_slot) - .map_err(unhandled_error)? - .ok_or_else(|| custom_bad_request(format!("prior state at slot {} unknown", prior_slot)))?; - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let mut state = chain - .get_state(&state_root, Some(prior_slot), true) - .and_then(|maybe_state| maybe_state.ok_or(BeaconChainError::MissingBeaconState(state_root))) - .map_err(unhandled_error)?; - - state - .build_caches(&chain.spec) - .map_err(beacon_state_error)?; - - let mut reward_cache = Default::default(); - let mut block_rewards = Vec::with_capacity(blocks.len()); - - let block_replayer = BlockReplayer::new(state, &chain.spec) - .pre_block_hook(Box::new(|state, block| { - state.build_all_committee_caches(&chain.spec)?; - - // Compute block reward. - let block_reward = chain.compute_block_reward( - block.message(), - block.canonical_root(), - state, - &mut reward_cache, - query.include_attestations, - )?; - block_rewards.push(block_reward); - Ok(()) - })) - .state_root_iter( - chain - .forwards_iter_state_roots_until(prior_slot, end_slot) - .map_err(unhandled_error)?, - ) - .no_signature_verification() - .minimal_block_root_verification() - .apply_blocks(blocks, None) - .map_err(unhandled_error)?; - - if block_replayer.state_root_miss() { - warn!(%start_slot, %end_slot, "Block reward state root miss"); - } - - drop(block_replayer); - - Ok(block_rewards) -} - -/// Compute block rewards for blocks passed in as input. -pub fn compute_block_rewards<T: BeaconChainTypes>( - blocks: Vec<BlindedBeaconBlock<T::EthSpec>>, - chain: Arc<BeaconChain<T>>, -) -> Result<Vec<BlockReward>, warp::Rejection> { - let mut block_rewards = Vec::with_capacity(blocks.len()); - let mut state_cache = LruCache::new(STATE_CACHE_SIZE); - let mut reward_cache = Default::default(); - - for block in blocks { - let parent_root = block.parent_root(); - - // Check LRU cache for a constructed state from a previous iteration. - let state = if let Some(state) = state_cache.get(&(parent_root, block.slot())) { - debug!( - ?parent_root, - slot = %block.slot(), - "Re-using cached state for block rewards" - ); - state - } else { - debug!( - ?parent_root, - slot = %block.slot(), - "Fetching state for block rewards" - ); - let parent_block = chain - .get_blinded_block(&parent_root) - .map_err(unhandled_error)? - .ok_or_else(|| { - custom_bad_request(format!( - "parent block not known or not canonical: {:?}", - parent_root - )) - })?; - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let parent_state = chain - .get_state(&parent_block.state_root(), Some(parent_block.slot()), true) - .map_err(unhandled_error)? - .ok_or_else(|| { - custom_bad_request(format!( - "no state known for parent block: {:?}", - parent_root - )) - })?; - - let block_replayer = BlockReplayer::new(parent_state, &chain.spec) - .no_signature_verification() - .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) - .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) - .map_err(unhandled_error::<BeaconChainError>)?; - - if block_replayer.state_root_miss() { - warn!( - parent_slot = %parent_block.slot(), - slot = %block.slot(), - "Block reward state root miss" - ); - } - - let mut state = block_replayer.into_state(); - state - .build_all_committee_caches(&chain.spec) - .map_err(beacon_state_error)?; - - state_cache.get_or_insert((parent_root, block.slot()), || state) - }; - - // Compute block reward. - let block_reward = chain - .compute_block_reward( - block.to_ref(), - block.canonical_root(), - state, - &mut reward_cache, - true, - ) - .map_err(unhandled_error)?; - block_rewards.push(block_reward); - } - - Ok(block_rewards) -} diff --git a/beacon_node/http_api/src/builder_states.rs b/beacon_node/http_api/src/builder_states.rs index 7c05dd00d2..73e01debcd 100644 --- a/beacon_node/http_api/src/builder_states.rs +++ b/beacon_node/http_api/src/builder_states.rs @@ -32,7 +32,7 @@ pub fn get_next_withdrawals<T: BeaconChainTypes>( } match get_expected_withdrawals(&state, &chain.spec) { - Ok((withdrawals, _)) => Ok(withdrawals), + Ok(expected_withdrawals) => Ok(expected_withdrawals.into()), Err(e) => Err(warp_utils::reject::custom_server_error(format!( "failed to get expected withdrawal: {:?}", e diff --git a/beacon_node/http_api/src/database.rs b/beacon_node/http_api/src/database.rs index 8a50ec45b0..4737d92079 100644 --- a/beacon_node/http_api/src/database.rs +++ b/beacon_node/http_api/src/database.rs @@ -2,6 +2,7 @@ use beacon_chain::store::metadata::CURRENT_SCHEMA_VERSION; use beacon_chain::{BeaconChain, BeaconChainTypes}; use serde::Serialize; use std::sync::Arc; +use store::invariants::InvariantCheckResult; use store::{AnchorInfo, BlobInfo, Split, StoreConfig}; #[derive(Debug, Serialize)] @@ -30,3 +31,11 @@ pub fn info<T: BeaconChainTypes>( blob_info, }) } + +pub fn check_invariants<T: BeaconChainTypes>( + chain: Arc<BeaconChain<T>>, +) -> Result<InvariantCheckResult, warp::Rejection> { + chain.check_database_invariants().map_err(|e| { + warp_utils::reject::custom_bad_request(format!("error checking database invariants: {e:?}")) + }) +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index c38ae4bc18..53311cbd22 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] //! This crate contains a HTTP server which serves the endpoints listed here: //! //! https://github.com/ethereum/beacon-APIs @@ -6,12 +7,9 @@ //! used for development. mod aggregate_attestation; -mod attestation_performance; mod attester_duties; mod beacon; mod block_id; -mod block_packing_efficiency; -mod block_rewards; mod build_block_contents; mod builder_states; mod custody; @@ -22,6 +20,7 @@ mod metrics; mod peer; mod produce_block; mod proposer_duties; +mod ptc_duties; mod publish_attestations; mod publish_blocks; mod publish_inclusion_lists; @@ -38,6 +37,10 @@ mod validator_inclusion; mod validators; mod version; +use crate::beacon::execution_payload_envelope::{ + get_beacon_execution_payload_envelope, post_beacon_execution_payload_envelope, + post_beacon_execution_payload_envelope_ssz, +}; use crate::beacon::pool::*; use crate::light_client::{get_light_client_bootstrap, get_light_client_updates}; use crate::utils::{AnyVersionFilter, EthV1Filter}; @@ -52,7 +55,6 @@ use builder_states::get_next_withdrawals; use bytes::Bytes; use context_deserialize::ContextDeserialize; use directory::DEFAULT_ROOT_DIR; -use eth2::StatusCode; use eth2::lighthouse::sync_state::SyncState; use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData, @@ -71,6 +73,7 @@ use parking_lot::RwLock; pub use publish_blocks::{ ProvenancedBlock, publish_blinded_block, publish_block, reconstruct_block, }; +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use slot_clock::SlotClock; use ssz::Encode; @@ -94,6 +97,7 @@ use types::{ BeaconStateError, Checkpoint, ConfigAndPreset, Epoch, EthSpec, ForkName, Hash256, SignedBlindedBeaconBlock, SignedInclusionList, Slot, }; +use validator::execution_payload_envelope::get_validator_execution_payload_envelope; use version::{ ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection, @@ -262,6 +266,7 @@ pub fn prometheus_metrics() -> warp::filters::log::Log<impl Fn(warp::filters::lo .or_else(|| starts_with("v1/validator/contribution_and_proofs")) .or_else(|| starts_with("v1/validator/duties/attester")) .or_else(|| starts_with("v1/validator/duties/proposer")) + .or_else(|| starts_with("v2/validator/duties/proposer")) .or_else(|| starts_with("v1/validator/duties/sync")) .or_else(|| starts_with("v1/validator/inclusion_list")) .or_else(|| starts_with("v1/validator/liveness")) @@ -649,6 +654,10 @@ pub fn serve<T: BeaconChainTypes>( let get_beacon_state_pending_consolidations = states::get_beacon_state_pending_consolidations(beacon_states_path.clone()); + // GET beacon/states/{state_id}/proposer_lookahead + let get_beacon_state_proposer_lookahead = + states::get_beacon_state_proposer_lookahead(beacon_states_path.clone()); + // GET beacon/headers // // Note: this endpoint only returns information about blocks in the canonical chain. Given that @@ -1194,7 +1203,28 @@ pub fn serve<T: BeaconChainTypes>( Priority::P1 }; task_spawner.blocking_json_task(priority, move || { - let (block_root, execution_optimistic, finalized) = block_id.root(&chain)?; + // Fast-path for the head block root. We read from the early attester cache + // so that we can produce sync committee messages for the new head prior + // to it being fully imported (written to the DB/etc). We also check that the + // cache is not stale or out of date by comparing against the cached head + // prior to using it. + // + // See: https://github.com/sigp/lighthouse/issues/8667 + let (block_root, execution_optimistic, finalized) = + if let BlockId(eth2::types::BlockId::Head) = block_id + && let Some((head_block_slot, head_block_root)) = + chain.early_attester_cache.get_head_block_root() + && head_block_slot >= chain.canonical_head.cached_head().head_slot() + { + // We know execution is NOT optimistic if the block is from the early + // attester cache because only properly validated blocks are added. + // Similarly we know it is NOT finalized. + let execution_optimistic = false; + let finalized = false; + (head_block_root, execution_optimistic, finalized) + } else { + block_id.root(&chain)? + }; Ok( api_types::GenericResponse::from(api_types::RootData::from(block_root)) .add_execution_optimistic_finalized(execution_optimistic, finalized), @@ -1428,7 +1458,7 @@ pub fn serve<T: BeaconChainTypes>( let post_beacon_pool_attestations_v2 = post_beacon_pool_attestations_v2( &network_tx_filter, - optional_consensus_version_header_filter, + optional_consensus_version_header_filter.clone(), &beacon_pool_path_v2, ); @@ -1461,6 +1491,21 @@ pub fn serve<T: BeaconChainTypes>( let post_beacon_pool_sync_committees = post_beacon_pool_sync_committees(&network_tx_filter, &beacon_pool_path); + // POST beacon/pool/payload_attestations + let post_beacon_pool_payload_attestations = post_beacon_pool_payload_attestations( + &network_tx_filter, + optional_consensus_version_header_filter, + &beacon_pool_path, + ); + + // POST beacon/pool/payload_attestations (SSZ) + let post_beacon_pool_payload_attestations_ssz = post_beacon_pool_payload_attestations_ssz( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + // GET beacon/pool/bls_to_execution_changes let get_beacon_pool_bls_to_execution_changes = get_beacon_pool_bls_to_execution_changes(&beacon_pool_path); @@ -1494,6 +1539,30 @@ pub fn serve<T: BeaconChainTypes>( }, ); + // POST beacon/execution_payload_envelope + let post_beacon_execution_payload_envelope = post_beacon_execution_payload_envelope( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + + // POST beacon/execution_payload_envelope (SSZ) + let post_beacon_execution_payload_envelope_ssz = post_beacon_execution_payload_envelope_ssz( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + + // GET beacon/execution_payload_envelope/{block_id} + let get_beacon_execution_payload_envelope = get_beacon_execution_payload_envelope( + eth_v1.clone(), + block_id_or_err, + task_spawner_filter.clone(), + chain_filter.clone(), + ); + let beacon_rewards_path = eth_v1 .clone() .and(warp::path("beacon")) @@ -1784,8 +1853,16 @@ pub fn serve<T: BeaconChainTypes>( let execution_optimistic = chain.is_optimistic_or_invalid_head().unwrap_or_default(); - Ok(api_types::GenericResponse::from(attestation_rewards)) - .map(|resp| resp.add_execution_optimistic(execution_optimistic)) + let finalized = epoch + 2 + <= chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch; + + Ok(api_types::GenericResponse::from(attestation_rewards)).map(|resp| { + resp.add_execution_optimistic_finalized(execution_optimistic, finalized) + }) }) }, ); @@ -2066,52 +2143,66 @@ pub fn serve<T: BeaconChainTypes>( .nodes .iter() .map(|node| { - let execution_status = if node.execution_status.is_execution_enabled() { - Some(node.execution_status.to_string()) + let execution_status = if node + .execution_status() + .is_ok_and(|status| status.is_execution_enabled()) + { + node.execution_status() + .ok() + .map(|status| status.to_string()) } else { None }; + let execution_status_string = node + .execution_status() + .map_or_else(|_| "irrelevant".to_string(), |s| s.to_string()); + ForkChoiceNode { - slot: node.slot, - block_root: node.root, + slot: node.slot(), + block_root: node.root(), parent_root: node - .parent + .parent() .and_then(|index| proto_array.nodes.get(index)) - .map(|parent| parent.root), - justified_epoch: node.justified_checkpoint.epoch, - finalized_epoch: node.finalized_checkpoint.epoch, - weight: node.weight, + .map(|parent| parent.root()), + justified_epoch: node.justified_checkpoint().epoch, + finalized_epoch: node.finalized_checkpoint().epoch, + weight: node.weight(), validity: execution_status, execution_block_hash: node - .execution_status - .block_hash() + .execution_status() + .ok() + .and_then(|status| status.block_hash()) .map(|block_hash| block_hash.into_root()), extra_data: ForkChoiceExtraData { - target_root: node.target_root, - justified_root: node.justified_checkpoint.root, - finalized_root: node.finalized_checkpoint.root, + target_root: node.target_root(), + justified_root: node.justified_checkpoint().root, + finalized_root: node.finalized_checkpoint().root, unrealized_justified_root: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_finalized_root: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_justified_epoch: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.epoch), unrealized_finalized_epoch: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.epoch), - execution_status: node.execution_status.to_string(), + execution_status: execution_status_string, best_child: node - .best_child + .best_child() + .ok() + .flatten() .and_then(|index| proto_array.nodes.get(index)) - .map(|child| child.root), + .map(|child| child.root()), best_descendant: node - .best_descendant + .best_descendant() + .ok() + .flatten() .and_then(|index| proto_array.nodes.get(index)) - .map(|descendant| descendant.root), + .map(|descendant| descendant.root()), }, } }) @@ -2148,12 +2239,9 @@ pub fn serve<T: BeaconChainTypes>( let discovery_addresses = enr.multiaddr_p2p_udp(); Ok(api_types::GenericResponse::from(api_types::IdentityData { peer_id: network_globals.local_peer_id().to_base58(), - enr: enr.to_base64(), - p2p_addresses: p2p_addresses.iter().map(|a| a.to_string()).collect(), - discovery_addresses: discovery_addresses - .iter() - .map(|a| a.to_string()) - .collect(), + enr, + p2p_addresses, + discovery_addresses, metadata: utils::from_meta_data::<T::EthSpec>( &network_globals.local_metadata, &chain.spec, @@ -2455,7 +2543,7 @@ pub fn serve<T: BeaconChainTypes>( // GET validator/duties/proposer/{epoch} let get_validator_duties_proposer = get_validator_duties_proposer( - eth_v1.clone().clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2463,7 +2551,7 @@ pub fn serve<T: BeaconChainTypes>( // GET validator/blocks/{slot} let get_validator_blocks = get_validator_blocks( - any_version.clone().clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2471,7 +2559,15 @@ pub fn serve<T: BeaconChainTypes>( // GET validator/blinded_blocks/{slot} let get_validator_blinded_blocks = get_validator_blinded_blocks( - eth_v1.clone().clone(), + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + + // GET validator/execution_payload_envelope/{slot}/{builder_index} + let get_validator_execution_payload_envelope = get_validator_execution_payload_envelope( + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2479,7 +2575,15 @@ pub fn serve<T: BeaconChainTypes>( // GET validator/attestation_data?slot,committee_index let get_validator_attestation_data = get_validator_attestation_data( - eth_v1.clone().clone(), + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + + // GET validator/payload_attestation_data/{slot} + let get_validator_payload_attestation_data = get_validator_payload_attestation_data( + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2487,7 +2591,7 @@ pub fn serve<T: BeaconChainTypes>( // GET validator/aggregate_attestation?attestation_data_root,slot let get_validator_aggregate_attestation = get_validator_aggregate_attestation( - any_version.clone().clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2495,7 +2599,15 @@ pub fn serve<T: BeaconChainTypes>( // POST validator/duties/attester/{epoch} let post_validator_duties_attester = post_validator_duties_attester( - eth_v1.clone().clone(), + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + + // POST validator/duties/ptc/{epoch} + let post_validator_duties_ptc = post_validator_duties_ptc( + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2503,7 +2615,7 @@ pub fn serve<T: BeaconChainTypes>( // POST validator/duties/sync/{epoch} let post_validator_duties_sync = post_validator_duties_sync( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2541,7 +2653,7 @@ pub fn serve<T: BeaconChainTypes>( // GET validator/sync_committee_contribution let get_validator_sync_committee_contribution = get_validator_sync_committee_contribution( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2599,7 +2711,7 @@ pub fn serve<T: BeaconChainTypes>( ); // POST validator/aggregate_and_proofs let post_validator_aggregate_and_proofs = post_validator_aggregate_and_proofs( - any_version.clone().clone(), + any_version.clone(), chain_filter.clone(), network_tx_filter.clone(), not_while_syncing_filter.clone(), @@ -2607,7 +2719,7 @@ pub fn serve<T: BeaconChainTypes>( ); let post_validator_contribution_and_proofs = post_validator_contribution_and_proofs( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), network_tx_filter.clone(), not_while_syncing_filter.clone(), @@ -2617,7 +2729,7 @@ pub fn serve<T: BeaconChainTypes>( // POST validator/beacon_committee_subscriptions let post_validator_beacon_committee_subscriptions = post_validator_beacon_committee_subscriptions( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), validator_subscription_tx_filter.clone(), task_spawner_filter.clone(), @@ -2625,7 +2737,7 @@ pub fn serve<T: BeaconChainTypes>( // POST validator/prepare_beacon_proposer let post_validator_prepare_beacon_proposer = post_validator_prepare_beacon_proposer( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), network_tx_filter.clone(), not_while_syncing_filter.clone(), @@ -2634,13 +2746,13 @@ pub fn serve<T: BeaconChainTypes>( // POST validator/register_validator let post_validator_register_validator = post_validator_register_validator( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), task_spawner_filter.clone(), ); // POST validator/sync_committee_subscriptions let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), validator_subscription_tx_filter.clone(), task_spawner_filter.clone(), @@ -2648,7 +2760,7 @@ pub fn serve<T: BeaconChainTypes>( // POST validator/liveness/{epoch} let post_validator_liveness_epoch = post_validator_liveness_epoch( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), task_spawner_filter.clone(), ); @@ -3065,6 +3177,19 @@ pub fn serve<T: BeaconChainTypes>( }, ); + // GET lighthouse/database/invariants + let get_lighthouse_database_invariants = database_path + .and(warp::path("invariants")) + .and(warp::path::end()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |task_spawner: TaskSpawner<T::EthSpec>, chain: Arc<BeaconChain<T>>| { + task_spawner + .blocking_json_task(Priority::P1, move || database::check_invariants(chain)) + }, + ); + // POST lighthouse/database/reconstruct let post_lighthouse_database_reconstruct = database_path .and(warp::path("reconstruct")) @@ -3128,86 +3253,6 @@ pub fn serve<T: BeaconChainTypes>( }, ); - // GET lighthouse/analysis/block_rewards - let get_lighthouse_block_rewards = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("block_rewards")) - .and(warp::query::<eth2::lighthouse::BlockRewardsQuery>()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then(|query, task_spawner: TaskSpawner<T::EthSpec>, chain| { - task_spawner.blocking_json_task(Priority::P1, move || { - block_rewards::get_block_rewards(query, chain) - }) - }); - - // POST lighthouse/analysis/block_rewards - let post_lighthouse_block_rewards = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("block_rewards")) - .and(warp_utils::json::json()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then(|blocks, task_spawner: TaskSpawner<T::EthSpec>, chain| { - task_spawner.blocking_json_task(Priority::P1, move || { - block_rewards::compute_block_rewards(blocks, chain) - }) - }); - - // GET lighthouse/analysis/attestation_performance/{index} - let get_lighthouse_attestation_performance = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("attestation_performance")) - .and(warp::path::param::<String>()) - .and(warp::query::<eth2::lighthouse::AttestationPerformanceQuery>()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then( - |target, query, task_spawner: TaskSpawner<T::EthSpec>, chain: Arc<BeaconChain<T>>| { - task_spawner.blocking_json_task(Priority::P1, move || { - attestation_performance::get_attestation_performance(target, query, chain) - }) - }, - ); - - // GET lighthouse/analysis/block_packing_efficiency - let get_lighthouse_block_packing_efficiency = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("block_packing_efficiency")) - .and(warp::query::<eth2::lighthouse::BlockPackingEfficiencyQuery>()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then( - |query, task_spawner: TaskSpawner<T::EthSpec>, chain: Arc<BeaconChain<T>>| { - task_spawner.blocking_json_task(Priority::P1, move || { - block_packing_efficiency::get_block_packing_efficiency(query, chain) - }) - }, - ); - - // GET lighthouse/merge_readiness - let get_lighthouse_merge_readiness = warp::path("lighthouse") - .and(warp::path("merge_readiness")) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then( - |task_spawner: TaskSpawner<T::EthSpec>, chain: Arc<BeaconChain<T>>| { - task_spawner.spawn_async_with_rejection(Priority::P1, async move { - let current_slot = chain.slot_clock.now_or_genesis().unwrap_or(Slot::new(0)); - let merge_readiness = chain.check_bellatrix_readiness(current_slot).await; - Ok::<_, warp::reject::Rejection>( - warp::reply::json(&api_types::GenericResponse::from(merge_readiness)) - .into_response(), - ) - }) - }, - ); - let get_events = eth_v1 .clone() .and(warp::path("events")) @@ -3265,9 +3310,6 @@ pub fn serve<T: BeaconChainTypes>( api_types::EventTopic::LightClientOptimisticUpdate => { event_handler.subscribe_light_client_optimistic_update() } - api_types::EventTopic::BlockReward => { - event_handler.subscribe_block_reward() - } api_types::EventTopic::AttesterSlashing => { event_handler.subscribe_attester_slashing() } @@ -3283,6 +3325,21 @@ pub fn serve<T: BeaconChainTypes>( api_types::EventTopic::InclusionList => { event_handler.subscribe_inclusion_list() } + api_types::EventTopic::ExecutionPayload => { + event_handler.subscribe_execution_payload() + } + api_types::EventTopic::ExecutionPayloadGossip => { + event_handler.subscribe_execution_payload_gossip() + } + api_types::EventTopic::ExecutionPayloadAvailable => { + event_handler.subscribe_execution_payload_available() + } + api_types::EventTopic::ExecutionPayloadBid => { + event_handler.subscribe_execution_payload_bid() + } + api_types::EventTopic::PayloadAttestationMessage => { + event_handler.subscribe_payload_attestation_message() + } }; receivers.push( @@ -3317,7 +3374,19 @@ pub fn serve<T: BeaconChainTypes>( let s = futures::stream::select_all(receivers); - Ok(warp::sse::reply(warp::sse::keep_alive().stream(s))) + let response = warp::sse::reply(warp::sse::keep_alive().stream(s)); + + // Set headers to bypass nginx caching and buffering, which breaks realtime + // delivery. + let response = warp::reply::with_header(response, "X-Accel-Buffering", "no"); + let response = warp::reply::with_header(response, "X-Accel-Expires", "0"); + let response = warp::reply::with_header( + response, + "Cache-Control", + "no-cache, no-store, must-revalidate", + ); + + Ok(response) }) }, ); @@ -3387,6 +3456,7 @@ pub fn serve<T: BeaconChainTypes>( .uor(get_beacon_state_pending_deposits) .uor(get_beacon_state_pending_partial_withdrawals) .uor(get_beacon_state_pending_consolidations) + .uor(get_beacon_state_proposer_lookahead) .uor(get_beacon_headers) .uor(get_beacon_headers_block_id) .uor(get_beacon_block) @@ -3395,6 +3465,7 @@ pub fn serve<T: BeaconChainTypes>( .uor(get_beacon_block_root) .uor(get_blob_sidecars) .uor(get_blobs) + .uor(get_beacon_execution_payload_envelope) .uor(get_beacon_pool_attestations) .uor(get_beacon_pool_attester_slashings) .uor(get_beacon_pool_proposer_slashings) @@ -3418,7 +3489,9 @@ pub fn serve<T: BeaconChainTypes>( .uor(get_validator_duties_proposer) .uor(get_validator_blocks) .uor(get_validator_blinded_blocks) + .uor(get_validator_execution_payload_envelope) .uor(get_validator_attestation_data) + .uor(get_validator_payload_attestation_data) .uor(get_validator_aggregate_attestation) .uor(get_validator_sync_committee_contribution) .uor(get_validator_inclusion_list) @@ -3434,15 +3507,12 @@ pub fn serve<T: BeaconChainTypes>( .uor(get_lighthouse_validator_inclusion) .uor(get_lighthouse_staking) .uor(get_lighthouse_database_info) + .uor(get_lighthouse_database_invariants) .uor(get_lighthouse_custody_info) - .uor(get_lighthouse_block_rewards) - .uor(get_lighthouse_attestation_performance) .uor(get_beacon_light_client_optimistic_update) .uor(get_beacon_light_client_finality_update) .uor(get_beacon_light_client_bootstrap) .uor(get_beacon_light_client_updates) - .uor(get_lighthouse_block_packing_efficiency) - .uor(get_lighthouse_merge_readiness) .uor(get_events) .uor(get_expected_withdrawals) .uor(lighthouse_log_events.boxed()) @@ -3457,7 +3527,9 @@ pub fn serve<T: BeaconChainTypes>( post_beacon_blocks_ssz .uor(post_beacon_blocks_v2_ssz) .uor(post_beacon_blinded_blocks_ssz) - .uor(post_beacon_blinded_blocks_v2_ssz), + .uor(post_beacon_blinded_blocks_v2_ssz) + .uor(post_beacon_execution_payload_envelope_ssz) + .uor(post_beacon_pool_payload_attestations_ssz), ) .uor(post_beacon_blocks) .uor(post_beacon_blinded_blocks) @@ -3468,13 +3540,16 @@ pub fn serve<T: BeaconChainTypes>( .uor(post_beacon_pool_proposer_slashings) .uor(post_beacon_pool_voluntary_exits) .uor(post_beacon_pool_sync_committees) + .uor(post_beacon_pool_payload_attestations) .uor(post_beacon_pool_bls_to_execution_changes) + .uor(post_beacon_execution_payload_envelope) .uor(post_beacon_state_validators) .uor(post_beacon_state_validator_balances) .uor(post_beacon_state_validator_identities) .uor(post_beacon_rewards_attestations) .uor(post_beacon_rewards_sync_committee) .uor(post_validator_duties_attester) + .uor(post_validator_duties_ptc) .uor(post_validator_duties_sync) .uor(post_validator_duties_inclusion_list) .uor(post_validator_aggregate_and_proofs) @@ -3486,7 +3561,6 @@ pub fn serve<T: BeaconChainTypes>( .uor(post_validator_liveness_epoch) .uor(post_lighthouse_liveness) .uor(post_lighthouse_database_reconstruct) - .uor(post_lighthouse_block_rewards) .uor(post_lighthouse_ui_validator_metrics) .uor(post_lighthouse_ui_validator_info) .uor(post_beacon_pool_inclusion_lists) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 3bd0cec7e3..7173eb698f 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -10,13 +10,12 @@ use beacon_chain::graffiti_calculator::GraffitiSettings; use beacon_chain::{ BeaconBlockResponseWrapper, BeaconChain, BeaconChainTypes, ProduceBlockVerification, }; -use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{self as api_types, ProduceBlockV3Metadata, SkipRandaoVerification}; -use lighthouse_tracing::{SPAN_PRODUCE_BLOCK_V2, SPAN_PRODUCE_BLOCK_V3}; +use eth2::{beacon_response::ForkVersionedResponse, types::ProduceBlockV4Metadata}; use ssz::Encode; use std::sync::Arc; use tracing::instrument; -use types::{payload::BlockProductionVersion, *}; +use types::{execution::BlockProductionVersion, *}; use warp::{ Reply, hyper::{Body, Response}, @@ -45,7 +44,50 @@ pub fn get_randao_verification( } #[instrument( - name = SPAN_PRODUCE_BLOCK_V3, + name = "lh_produce_block_v4", + skip_all, + fields(%slot) +)] +pub async fn produce_block_v4<T: BeaconChainTypes>( + accept_header: Option<api_types::Accept>, + chain: Arc<BeaconChain<T>>, + slot: Slot, + query: api_types::ValidatorBlocksQuery, +) -> Result<Response<Body>, warp::Rejection> { + let randao_reveal = query.randao_reveal.decompress().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "randao reveal is not a valid BLS signature: {:?}", + e + )) + })?; + + let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; + let builder_boost_factor = if query.builder_boost_factor == Some(DEFAULT_BOOST_FACTOR) { + None + } else { + query.builder_boost_factor + }; + + let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); + + let (block, _block_state, consensus_block_value) = chain + .produce_block_with_verification_gloas( + randao_reveal, + slot, + graffiti_settings, + randao_verification, + builder_boost_factor, + ) + .await + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) + })?; + + build_response_v4::<T>(block, consensus_block_value, accept_header, &chain.spec) +} + +#[instrument( + name = "lh_produce_block_v3", skip_all, fields(%slot) )] @@ -88,6 +130,45 @@ pub async fn produce_block_v3<T: BeaconChainTypes>( build_response_v3(chain, block_response_type, accept_header) } +pub fn build_response_v4<T: BeaconChainTypes>( + block: BeaconBlock<T::EthSpec, FullPayload<T::EthSpec>>, + consensus_block_value: u64, + accept_header: Option<api_types::Accept>, + spec: &ChainSpec, +) -> Result<Response<Body>, warp::Rejection> { + let fork_name = block + .to_ref() + .fork_name(spec) + .map_err(inconsistent_fork_rejection)?; + let consensus_block_value_wei = + Uint256::from(consensus_block_value) * Uint256::from(1_000_000_000u64); + + let metadata = ProduceBlockV4Metadata { + consensus_version: fork_name, + consensus_block_value: consensus_block_value_wei, + }; + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(block.as_ssz_bytes().into()) + .map(|res: Response<Body>| add_ssz_content_type_header(res)) + .map(|res: Response<Body>| add_consensus_version_header(res, fork_name)) + .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)) + .map_err(|e| -> warp::Rejection { + warp_utils::reject::custom_server_error(format!("failed to create response: {}", e)) + }), + _ => Ok(warp::reply::json(&ForkVersionedResponse { + version: fork_name, + metadata, + data: block, + }) + .into_response()) + .map(|res| add_consensus_version_header(res, fork_name)) + .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)), + } +} + pub fn build_response_v3<T: BeaconChainTypes>( chain: Arc<BeaconChain<T>>, block_response: BeaconBlockResponseWrapper<T::EthSpec>, @@ -169,7 +250,7 @@ pub async fn produce_blinded_block_v2<T: BeaconChainTypes>( } #[instrument( - name = SPAN_PRODUCE_BLOCK_V2, + name = "lh_produce_block_v2", skip_all, fields(%slot) )] diff --git a/beacon_node/http_api/src/proposer_duties.rs b/beacon_node/http_api/src/proposer_duties.rs index 1ebb174785..0b0926f955 100644 --- a/beacon_node/http_api/src/proposer_duties.rs +++ b/beacon_node/http_api/src/proposer_duties.rs @@ -13,13 +13,45 @@ use slot_clock::SlotClock; use tracing::debug; use types::{Epoch, EthSpec, Hash256, Slot}; +/// Selects which dependent root to return in the API response. +/// +/// - `Legacy`: the block root at the last slot of epoch N-1 (v1 behaviour, for backwards compat). +/// - `True`: the fork-aware proposer shuffling decision root (v2 behaviour). Pre-Fulu this equals +/// the legacy root; post-Fulu it uses epoch N-2. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DependentRootSelection { + Legacy, + True, +} + /// The struct that is returned to the requesting HTTP client. type ApiDuties = api_types::DutiesResponse<Vec<api_types::ProposerData>>; -/// Handles a request from the HTTP API for proposer duties. +/// Handles a request from the HTTP API for v1 proposer duties. +/// +/// Returns the legacy dependent root (block root at end of epoch N-1) for backwards compatibility. pub fn proposer_duties<T: BeaconChainTypes>( request_epoch: Epoch, chain: &BeaconChain<T>, +) -> Result<ApiDuties, warp::reject::Rejection> { + proposer_duties_internal(request_epoch, chain, DependentRootSelection::Legacy) +} + +/// Handles a request from the HTTP API for v2 proposer duties. +/// +/// Returns the true fork-aware dependent root. Pre-Fulu this equals the legacy root; post-Fulu it +/// uses epoch N-2 due to deterministic proposer lookahead with `min_seed_lookahead`. +pub fn proposer_duties_v2<T: BeaconChainTypes>( + request_epoch: Epoch, + chain: &BeaconChain<T>, +) -> Result<ApiDuties, warp::reject::Rejection> { + proposer_duties_internal(request_epoch, chain, DependentRootSelection::True) +} + +fn proposer_duties_internal<T: BeaconChainTypes>( + request_epoch: Epoch, + chain: &BeaconChain<T>, + root_selection: DependentRootSelection, ) -> Result<ApiDuties, warp::reject::Rejection> { let current_epoch = chain .slot_clock @@ -49,24 +81,29 @@ pub fn proposer_duties<T: BeaconChainTypes>( if request_epoch == current_epoch || request_epoch == tolerant_current_epoch { // If we could consider ourselves in the `request_epoch` when allowing for clock disparity // tolerance then serve this request from the cache. - if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain)? { + if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain, root_selection)? + { Ok(duties) } else { debug!(%request_epoch, "Proposer cache miss"); - compute_and_cache_proposer_duties(request_epoch, chain) + compute_and_cache_proposer_duties(request_epoch, chain, root_selection) } } else if request_epoch == current_epoch .safe_add(1) .map_err(warp_utils::reject::arith_error)? { - let (proposers, _dependent_root, legacy_dependent_root, execution_status, _fork) = + let (proposers, dependent_root, legacy_dependent_root, execution_status, _fork) = compute_proposer_duties_from_head(request_epoch, chain) .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => legacy_dependent_root, + DependentRootSelection::True => dependent_root, + }; convert_to_api_response( chain, request_epoch, - legacy_dependent_root, + selected_root, execution_status.is_optimistic_or_invalid(), proposers, ) @@ -84,7 +121,7 @@ pub fn proposer_duties<T: BeaconChainTypes>( // request_epoch < current_epoch // // Queries about the past are handled with a slow path. - compute_historic_proposer_duties(request_epoch, chain) + compute_historic_proposer_duties(request_epoch, chain, root_selection) } } @@ -98,6 +135,7 @@ pub fn proposer_duties<T: BeaconChainTypes>( fn try_proposer_duties_from_cache<T: BeaconChainTypes>( request_epoch: Epoch, chain: &BeaconChain<T>, + root_selection: DependentRootSelection, ) -> Result<Option<ApiDuties>, warp::reject::Rejection> { let head = chain.canonical_head.cached_head(); let head_block = &head.snapshot.beacon_block; @@ -116,11 +154,14 @@ fn try_proposer_duties_from_cache<T: BeaconChainTypes>( .beacon_state .proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root, &chain.spec) .map_err(warp_utils::reject::beacon_state_error)?; - let legacy_dependent_root = head - .snapshot - .beacon_state - .legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root) - .map_err(warp_utils::reject::beacon_state_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => head + .snapshot + .beacon_state + .legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root) + .map_err(warp_utils::reject::beacon_state_error)?, + DependentRootSelection::True => head_decision_root, + }; let execution_optimistic = chain .is_optimistic_or_invalid_head_block(head_block) .map_err(warp_utils::reject::unhandled_error)?; @@ -134,7 +175,7 @@ fn try_proposer_duties_from_cache<T: BeaconChainTypes>( convert_to_api_response( chain, request_epoch, - legacy_dependent_root, + selected_root, execution_optimistic, indices.to_vec(), ) @@ -155,6 +196,7 @@ fn try_proposer_duties_from_cache<T: BeaconChainTypes>( fn compute_and_cache_proposer_duties<T: BeaconChainTypes>( current_epoch: Epoch, chain: &BeaconChain<T>, + root_selection: DependentRootSelection, ) -> Result<ApiDuties, warp::reject::Rejection> { let (indices, dependent_root, legacy_dependent_root, execution_status, fork) = compute_proposer_duties_from_head(current_epoch, chain) @@ -168,10 +210,14 @@ fn compute_and_cache_proposer_duties<T: BeaconChainTypes>( .map_err(BeaconChainError::from) .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => legacy_dependent_root, + DependentRootSelection::True => dependent_root, + }; convert_to_api_response( chain, current_epoch, - legacy_dependent_root, + selected_root, execution_status.is_optimistic_or_invalid(), indices, ) @@ -182,6 +228,7 @@ fn compute_and_cache_proposer_duties<T: BeaconChainTypes>( fn compute_historic_proposer_duties<T: BeaconChainTypes>( epoch: Epoch, chain: &BeaconChain<T>, + root_selection: DependentRootSelection, ) -> Result<ApiDuties, warp::reject::Rejection> { // If the head is quite old then it might still be relevant for a historical request. // @@ -219,9 +266,9 @@ fn compute_historic_proposer_duties<T: BeaconChainTypes>( }; // Ensure the state lookup was correct. - if state.current_epoch() != epoch { + if state.current_epoch() != epoch && state.current_epoch() + 1 != epoch { return Err(warp_utils::reject::custom_server_error(format!( - "state epoch {} not equal to request epoch {}", + "state from epoch {} cannot serve request epoch {}", state.current_epoch(), epoch ))); @@ -234,18 +281,18 @@ fn compute_historic_proposer_duties<T: BeaconChainTypes>( // We can supply the genesis block root as the block root since we know that the only block that // decides its own root is the genesis block. - let legacy_dependent_root = state - .legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root) - .map_err(BeaconChainError::from) - .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => state + .legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?, + DependentRootSelection::True => state + .proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root, &chain.spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?, + }; - convert_to_api_response( - chain, - epoch, - legacy_dependent_root, - execution_optimistic, - indices, - ) + convert_to_api_response(chain, epoch, selected_root, execution_optimistic, indices) } /// Converts the internal representation of proposer duties into one that is compatible with the diff --git a/beacon_node/http_api/src/ptc_duties.rs b/beacon_node/http_api/src/ptc_duties.rs new file mode 100644 index 0000000000..f727b84004 --- /dev/null +++ b/beacon_node/http_api/src/ptc_duties.rs @@ -0,0 +1,182 @@ +//! Contains the handler for the `POST validator/duties/ptc/{epoch}` endpoint. + +use crate::state_id::StateId; +use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; +use eth2::types::{self as api_types, PtcDuty}; +use slot_clock::SlotClock; +use state_processing::state_advance::partial_state_advance; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256}; + +type ApiDuties = api_types::DutiesResponse<Vec<PtcDuty>>; + +pub fn ptc_duties<T: BeaconChainTypes>( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain<T>, +) -> Result<ApiDuties, warp::reject::Rejection> { + let current_epoch = chain + .slot_clock + .now_or_genesis() + .map(|slot| slot.epoch(T::EthSpec::slots_per_epoch())) + .ok_or(BeaconChainError::UnableToReadSlot) + .map_err(warp_utils::reject::unhandled_error)?; + + let tolerant_current_epoch = if chain.slot_clock.is_prior_to_genesis().unwrap_or(true) { + current_epoch + } else { + chain + .slot_clock + .now_with_future_tolerance(chain.spec.maximum_gossip_clock_disparity()) + .ok_or_else(|| { + warp_utils::reject::custom_server_error("unable to read slot clock".into()) + })? + .epoch(T::EthSpec::slots_per_epoch()) + }; + + let is_within_clock_tolerance = request_epoch == current_epoch + || request_epoch == current_epoch + 1 + || request_epoch == tolerant_current_epoch + 1; + + if is_within_clock_tolerance { + let head_epoch = chain + .canonical_head + .cached_head() + .snapshot + .beacon_state + .current_epoch(); + + let head_can_serve_request = request_epoch == head_epoch || request_epoch == head_epoch + 1; + + if head_can_serve_request { + compute_ptc_duties_from_cached_head(request_epoch, request_indices, chain) + } else { + compute_ptc_duties_from_state(request_epoch, request_indices, chain) + } + } else if request_epoch > current_epoch + 1 { + Err(warp_utils::reject::custom_bad_request(format!( + "request epoch {} is more than one epoch past the current epoch {}", + request_epoch, current_epoch + ))) + } else { + compute_ptc_duties_from_state(request_epoch, request_indices, chain) + } +} + +fn compute_ptc_duties_from_cached_head<T: BeaconChainTypes>( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain<T>, +) -> Result<ApiDuties, warp::reject::Rejection> { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let state = &cached_head.snapshot.beacon_state; + let head_block_root = cached_head.head_block_root(); + + let (duties, dependent_root) = chain + .compute_ptc_duties(state, request_epoch, request_indices, head_block_root) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response( + duties, + dependent_root, + execution_status.is_optimistic_or_invalid(), + ) +} + +fn compute_ptc_duties_from_state<T: BeaconChainTypes>( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain<T>, +) -> Result<ApiDuties, warp::reject::Rejection> { + let state_opt = { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let head = &cached_head.snapshot; + + if head.beacon_state.current_epoch() <= request_epoch { + Some(( + head.beacon_state_root(), + head.beacon_state.clone(), + execution_status.is_optimistic_or_invalid(), + )) + } else { + None + } + }; + + let (state, execution_optimistic) = + if let Some((state_root, mut state, execution_optimistic)) = state_opt { + ensure_state_knows_ptc_duties_for_epoch( + &mut state, + state_root, + request_epoch, + &chain.spec, + )?; + (state, execution_optimistic) + } else { + let (state, execution_optimistic, _finalized) = + StateId::from_slot(request_epoch.start_slot(T::EthSpec::slots_per_epoch())) + .state(chain)?; + (state, execution_optimistic) + }; + + if !(state.current_epoch() == request_epoch || state.current_epoch() + 1 == request_epoch) { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} not suitable for request epoch {}", + state.current_epoch(), + request_epoch + ))); + } + + let (duties, dependent_root) = chain + .compute_ptc_duties( + &state, + request_epoch, + request_indices, + chain.genesis_block_root, + ) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response(duties, dependent_root, execution_optimistic) +} + +fn ensure_state_knows_ptc_duties_for_epoch<E: EthSpec>( + state: &mut BeaconState<E>, + state_root: Hash256, + target_epoch: Epoch, + spec: &ChainSpec, +) -> Result<(), warp::reject::Rejection> { + if state.current_epoch() > target_epoch { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} is later than target epoch {}", + state.current_epoch(), + target_epoch + ))); + } else if state.current_epoch() + 1 < target_epoch { + let target_slot = target_epoch + .saturating_sub(1_u64) + .start_slot(E::slots_per_epoch()); + + partial_state_advance(state, Some(state_root), target_slot, spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?; + } + + Ok(()) +} + +fn convert_to_api_response( + duties: Vec<Option<PtcDuty>>, + dependent_root: Hash256, + execution_optimistic: bool, +) -> Result<ApiDuties, warp::reject::Rejection> { + Ok(api_types::DutiesResponse { + dependent_root, + execution_optimistic: Some(execution_optimistic), + data: duties.into_iter().flatten().collect(), + }) +} diff --git a/beacon_node/http_api/src/publish_attestations.rs b/beacon_node/http_api/src/publish_attestations.rs index 947edf56d9..b93f2a0b7b 100644 --- a/beacon_node/http_api/src/publish_attestations.rs +++ b/beacon_node/http_api/src/publish_attestations.rs @@ -35,15 +35,13 @@ //! appears that this validator is capable of producing valid //! attestations and there's no immediate cause for concern. use crate::task_spawner::{Priority, TaskSpawner}; -use beacon_chain::{ - AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes, - validator_monitor::timestamp_now, -}; +use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use beacon_processor::work_reprocessing_queue::{QueuedUnaggregate, ReprocessQueueMessage}; use beacon_processor::{Work, WorkEvent}; use eth2::types::Failure; use lighthouse_network::PubsubMessage; use network::NetworkMessage; +use slot_clock::SlotClock; use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc::UnboundedSender, oneshot}; @@ -138,7 +136,7 @@ pub async fn publish_attestations<T: BeaconChainTypes>( .collect::<Vec<_>>(); // Gossip validate and publish attestations that can be immediately processed. - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let mut prelim_results = task_spawner .clone() .blocking_task(Priority::P0, move || { diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index b54c071eb8..644ade956a 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -2,26 +2,24 @@ use crate::metrics; use std::future::Future; use beacon_chain::blob_verification::{GossipBlobError, GossipVerifiedBlob}; -use beacon_chain::block_verification_types::{AsBlock, RpcBlock}; +use beacon_chain::block_verification_types::{AsBlock, LookupBlock}; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; -use beacon_chain::validator_monitor::{get_block_delay_ms, timestamp_now}; +use beacon_chain::validator_monitor::get_block_delay_ms; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, IntoGossipVerifiedBlock, NotifyExecutionLayer, build_blob_data_column_sidecars, }; -use eth2::{ - StatusCode, - types::{ - BlobsBundle, BroadcastValidation, ErrorMessage, ExecutionPayloadAndBlobs, - FullPayloadContents, PublishBlockRequest, SignedBlockContents, - }, +use eth2::types::{ + BlobsBundle, BroadcastValidation, ErrorMessage, ExecutionPayloadAndBlobs, FullPayloadContents, + PublishBlockRequest, SignedBlockContents, }; use execution_layer::{ProvenancedPayload, SubmitBlindedBlockResponse}; use futures::TryFutureExt; use lighthouse_network::PubsubMessage; -use lighthouse_tracing::SPAN_PUBLISH_BLOCK; +use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; +use reqwest::StatusCode; use slot_clock::SlotClock; use std::marker::PhantomData; use std::sync::Arc; @@ -32,8 +30,9 @@ use tracing::{Span, debug, debug_span, error, field, info, instrument, warn}; use tree_hash::TreeHash; use types::{ AbstractExecPayload, BeaconBlockRef, BlobSidecar, BlobsList, BlockImportSource, - DataColumnSubnetId, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, FullPayload, - FullPayloadBellatrix, Hash256, KzgProofs, SignedBeaconBlock, SignedBlindedBeaconBlock, + DataColumnSidecar, DataColumnSubnetId, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, + FullPayload, FullPayloadBellatrix, Hash256, KzgProofs, SignedBeaconBlock, + SignedBlindedBeaconBlock, }; use warp::{Rejection, Reply, reply::Response}; @@ -79,7 +78,7 @@ impl<T: BeaconChainTypes> ProvenancedBlock<T, Arc<SignedBeaconBlock<T::EthSpec>> /// Handles a request from the HTTP API for full blocks. #[allow(clippy::too_many_arguments)] #[instrument( - name = SPAN_PUBLISH_BLOCK, + name = "lh_publish_block", level = "info", skip_all, fields(block_root = field::Empty, ?validation_level, block_slot = field::Empty, provenance = field::Empty) @@ -92,7 +91,7 @@ pub async fn publish_block<T: BeaconChainTypes, B: IntoGossipVerifiedBlock<T>>( validation_level: BroadcastValidation, duplicate_status_code: StatusCode, ) -> Result<Response, Rejection> { - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let block_publishing_delay_for_testing = chain.config.block_publishing_delay; let data_column_publishing_delay_for_testing = chain.config.data_column_publishing_delay; @@ -117,11 +116,12 @@ pub async fn publish_block<T: BeaconChainTypes, B: IntoGossipVerifiedBlock<T>>( debug!("Signed block received in HTTP API"); /* actually publish a block */ + let publish_chain = chain.clone(); let publish_block_p2p = move |block: Arc<SignedBeaconBlock<T::EthSpec>>, sender, seen_timestamp| -> Result<(), BlockError> { - let publish_timestamp = timestamp_now(); + let publish_timestamp = publish_chain.slot_clock.now_duration().unwrap_or_default(); let publish_delay = publish_timestamp .checked_sub(seen_timestamp) .unwrap_or_else(|| Duration::from_secs(0)); @@ -150,12 +150,8 @@ pub async fn publish_block<T: BeaconChainTypes, B: IntoGossipVerifiedBlock<T>>( let slot = block.message().slot(); let sender_clone = network_tx.clone(); - let build_sidecar_task_handle = spawn_build_data_sidecar_task( - chain.clone(), - block.clone(), - unverified_blobs, - current_span.clone(), - )?; + let build_sidecar_task_handle = + spawn_build_data_sidecar_task(chain.clone(), block.clone(), unverified_blobs)?; // Gossip verify the block and blobs/data columns separately. let gossip_verified_block_result = unverified_block.into_gossip_verified_block(&chain); @@ -315,9 +311,11 @@ pub async fn publish_block<T: BeaconChainTypes, B: IntoGossipVerifiedBlock<T>>( slot = %block.slot(), "Block previously seen" ); + // try to reprocess as a lookup (single) block and let sync take care of missing components + let lookup_block = LookupBlock::new(block.clone()); let import_result = Box::pin(chain.process_block( block_root, - RpcBlock::new_without_blobs(Some(block_root), block.clone()), + lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::HttpApi, publish_fn, @@ -360,7 +358,6 @@ fn spawn_build_data_sidecar_task<T: BeaconChainTypes>( chain: Arc<BeaconChain<T>>, block: Arc<SignedBeaconBlock<T::EthSpec, FullPayload<T::EthSpec>>>, proofs_and_blobs: UnverifiedBlobs<T>, - current_span: Span, ) -> Result<impl Future<Output = BuildDataSidecarTaskResult<T>>, Rejection> { chain .clone() @@ -370,7 +367,7 @@ fn spawn_build_data_sidecar_task<T: BeaconChainTypes>( let Some((kzg_proofs, blobs)) = proofs_and_blobs else { return Ok((vec![], vec![])); }; - let _guard = debug_span!(parent: current_span, "build_data_sidecars").entered(); + let _span = debug_span!("build_data_sidecars").entered(); let peer_das_enabled = chain.spec.is_peer_das_enabled_for_epoch(block.epoch()); if !peer_das_enabled { @@ -497,7 +494,7 @@ fn publish_blob_sidecars<T: BeaconChainTypes>( .map_err(|_| BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish))) } -fn publish_column_sidecars<T: BeaconChainTypes>( +pub(crate) fn publish_column_sidecars<T: BeaconChainTypes>( sender_clone: &UnboundedSender<NetworkMessage<T::EthSpec>>, data_column_sidecars: &[GossipVerifiedDataColumn<T>], chain: &BeaconChain<T>, @@ -515,19 +512,57 @@ fn publish_column_sidecars<T: BeaconChainTypes>( data_column_sidecars.shuffle(&mut **chain.rng.lock()); let dropped_indices = data_column_sidecars .drain(columns_to_keep..) - .map(|d| d.index) + .map(|d| *d.index()) .collect::<Vec<_>>(); debug!(indices = ?dropped_indices, "Dropping data columns from publishing"); } - let pubsub_messages = data_column_sidecars - .into_iter() - .map(|data_col| { - let subnet = DataColumnSubnetId::from_column_index(data_col.index, &chain.spec); - PubsubMessage::DataColumnSidecar(Box::new((subnet, data_col))) - }) - .collect::<Vec<_>>(); - crate::utils::publish_pubsub_messages(sender_clone, pubsub_messages) - .map_err(|_| BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish))) + let mut full_messages = Vec::new(); + let mut partial_columns = Vec::new(); + let mut partial_header = None; + + for data_col in data_column_sidecars { + if chain.config.enable_partial_columns + && let DataColumnSidecar::Fulu(fulu_data_col) = data_col.as_ref() + { + let mut partial = fulu_data_col.to_partial(); + if let Some(header) = partial.sidecar.header.take() { + partial_header = Some(header); + } + partial_columns.push(Arc::new(partial)); + } + + let subnet = DataColumnSubnetId::from_column_index(*data_col.index(), &chain.spec); + full_messages.push(PubsubMessage::DataColumnSidecar(Box::new(( + subnet, data_col, + )))); + } + + // Publish full messages + if !full_messages.is_empty() { + crate::utils::publish_pubsub_messages(sender_clone, full_messages).map_err(|_| { + BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) + })?; + } + + // Publish partial messages + if !partial_columns.is_empty() { + if let Some(header) = partial_header { + crate::utils::publish_network_message( + sender_clone, + NetworkMessage::PublishPartialColumns { + columns: partial_columns, + header: Arc::new(header), + }, + ) + .map_err(|_| { + BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) + })?; + } else { + crit!("Unable to extract header from full columns") + } + } + + Ok(()) } async fn post_block_import_logging_and_response<T: BeaconChainTypes>( @@ -683,7 +718,7 @@ pub async fn reconstruct_block<T: BeaconChainTypes>( // us. late_block_logging( &chain, - timestamp_now(), + chain.slot_clock.now_duration().unwrap_or_default(), block.message(), block_root, "builder", @@ -762,7 +797,7 @@ fn late_block_logging<T: BeaconChainTypes, P: AbstractExecPayload<T::EthSpec>>( // // Check to see the thresholds are non-zero to avoid logging errors with small // slot times (e.g., during testing) - let too_late_threshold = chain.slot_clock.unagg_attestation_production_delay(); + let too_late_threshold = chain.spec.get_unaggregated_attestation_due(); let delayed_threshold = too_late_threshold / 2; if delay >= too_late_threshold { error!( diff --git a/beacon_node/http_api/src/publish_inclusion_lists.rs b/beacon_node/http_api/src/publish_inclusion_lists.rs index bcaf2050a8..b1bf670551 100644 --- a/beacon_node/http_api/src/publish_inclusion_lists.rs +++ b/beacon_node/http_api/src/publish_inclusion_lists.rs @@ -1,7 +1,8 @@ use std::{sync::Arc, time::Duration}; use beacon_chain::inclusion_list_verification::GossipInclusionListError; -use beacon_chain::{BeaconChain, BeaconChainTypes, validator_monitor::timestamp_now}; +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use slot_clock::timestamp_now; use eth2::types::Failure; use lighthouse_network::PubsubMessage; use network::NetworkMessage; diff --git a/beacon_node/http_api/src/sync_committees.rs b/beacon_node/http_api/src/sync_committees.rs index b9fa24ad6a..0dba4ff429 100644 --- a/beacon_node/http_api/src/sync_committees.rs +++ b/beacon_node/http_api/src/sync_committees.rs @@ -4,10 +4,7 @@ use crate::utils::publish_pubsub_message; use beacon_chain::sync_committee_verification::{ Error as SyncVerificationError, VerifiedSyncCommitteeMessage, }; -use beacon_chain::{ - BeaconChain, BeaconChainError, BeaconChainTypes, StateSkipConfig, - validator_monitor::timestamp_now, -}; +use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, StateSkipConfig}; use eth2::types::{self as api_types}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; @@ -17,8 +14,8 @@ use std::collections::HashMap; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, warn}; use types::{ - BeaconStateError, Epoch, EthSpec, SignedContributionAndProof, SyncCommitteeMessage, SyncDuty, - SyncSubnetId, slot_data::SlotData, + BeaconStateError, Epoch, EthSpec, SignedContributionAndProof, SlotData, SyncCommitteeMessage, + SyncDuty, SyncSubnetId, }; /// The struct that is returned to the requesting HTTP client. @@ -188,7 +185,7 @@ pub fn process_sync_committee_signatures<T: BeaconChainTypes>( ) -> Result<(), warp::reject::Rejection> { let mut failures = vec![]; - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); for (i, sync_committee_signature) in sync_committee_signatures.iter().enumerate() { let subnet_positions = match get_subnet_positions_for_sync_committee_message( @@ -235,6 +232,7 @@ pub fn process_sync_committee_signatures<T: BeaconChainTypes>( seen_timestamp, verified.sync_message(), &chain.slot_clock, + &chain.spec, ); verified_for_pool = Some(verified); @@ -318,7 +316,7 @@ pub fn process_signed_contribution_and_proofs<T: BeaconChainTypes>( let mut verified_contributions = Vec::with_capacity(signed_contribution_and_proofs.len()); let mut failures = vec![]; - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); if let Some(latest_optimistic_update) = chain .light_client_server_cache @@ -376,6 +374,7 @@ pub fn process_signed_contribution_and_proofs<T: BeaconChainTypes>( verified_contribution.aggregate(), verified_contribution.participant_pubkeys(), &chain.slot_clock, + &chain.spec, ); verified_contributions.push((index, verified_contribution)); diff --git a/beacon_node/http_api/src/ui.rs b/beacon_node/http_api/src/ui.rs index 1538215a0b..75ef2c63cb 100644 --- a/beacon_node/http_api/src/ui.rs +++ b/beacon_node/http_api/src/ui.rs @@ -215,24 +215,22 @@ pub fn post_validator_monitor_metrics<T: BeaconChainTypes>( drop(val_metrics); let attestations = attestation_hits + attestation_misses; - let attestation_hit_percentage: f64 = if attestations == 0 { - 0.0 - } else { - (100 * attestation_hits / attestations) as f64 - }; + let attestation_hit_percentage: f64 = (100 * attestation_hits) + .checked_div(attestations) + .map(|f| f as f64) + .unwrap_or(0.0); + let head_attestations = attestation_head_hits + attestation_head_misses; - let attestation_head_hit_percentage: f64 = if head_attestations == 0 { - 0.0 - } else { - (100 * attestation_head_hits / head_attestations) as f64 - }; + let attestation_head_hit_percentage: f64 = (100 * attestation_head_hits) + .checked_div(head_attestations) + .map(|f| f as f64) + .unwrap_or(0.0); let target_attestations = attestation_target_hits + attestation_target_misses; - let attestation_target_hit_percentage: f64 = if target_attestations == 0 { - 0.0 - } else { - (100 * attestation_target_hits / target_attestations) as f64 - }; + let attestation_target_hit_percentage: f64 = (100 * attestation_target_hits) + .checked_div(target_attestations) + .map(|f| f as f64) + .unwrap_or(0.0); let metrics = ValidatorMetrics { attestation_hits, diff --git a/beacon_node/http_api/src/validator/execution_payload_envelope.rs b/beacon_node/http_api/src/validator/execution_payload_envelope.rs new file mode 100644 index 0000000000..c40b375e49 --- /dev/null +++ b/beacon_node/http_api/src/validator/execution_payload_envelope.rs @@ -0,0 +1,110 @@ +use crate::task_spawner::{Priority, TaskSpawner}; +use crate::utils::{ + ChainFilter, EthV1Filter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, +}; +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use eth2::beacon_response::{EmptyMetadata, ForkVersionedResponse}; +use eth2::types::Accept; +use ssz::Encode; +use std::sync::Arc; +use tracing::debug; +use types::Slot; +use warp::http::Response; +use warp::{Filter, Rejection}; + +// GET validator/execution_payload_envelope/{slot}/{builder_index} +pub fn get_validator_execution_payload_envelope<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + chain_filter: ChainFilter<T>, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter<T>, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("execution_payload_envelope")) + .and(warp::path::param::<Slot>().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::param::<u64>().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid builder_index".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::<Accept>("accept")) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + // TODO(gloas) we're only doing local building + // we'll need to implement builder index logic + // eventually. + _builder_index: u64, + accept_header: Option<Accept>, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!(?slot, "Execution payload envelope request from HTTP API"); + + not_synced_filter?; + + // Get the envelope from the pending cache (local building only) + let envelope = chain + .pending_payload_envelopes + .read() + .get(slot) + .cloned() + .ok_or_else(|| { + warp_utils::reject::custom_not_found(format!( + "Execution payload envelope not available for slot {slot}" + )) + })?; + + let fork_name = chain.spec.fork_name_at_slot::<T::EthSpec>(slot); + + match accept_header { + Some(Accept::Ssz) => Response::builder() + .status(200) + .header("Content-Type", "application/octet-stream") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body(envelope.as_ssz_bytes().into()) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build SSZ response: {e}" + )) + }), + _ => { + let json_response = ForkVersionedResponse { + version: fork_name, + metadata: EmptyMetadata {}, + data: envelope, + }; + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body( + serde_json::to_string(&json_response) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to serialize response: {e}" + )) + })? + .into(), + ) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build JSON response: {e}" + )) + }) + } + } + }) + }, + ) + .boxed() +} diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 8baf7c5245..27fe5de6e7 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -1,16 +1,16 @@ -use crate::produce_block::{produce_blinded_block_v2, produce_block_v2, produce_block_v3}; +use crate::produce_block::{ + produce_blinded_block_v2, produce_block_v2, produce_block_v3, produce_block_v4, +}; use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::V3; -use crate::{StateId, attester_duties, proposer_duties, sync_committees}; +use crate::version::{V1, V2, V3, unsupported_version_rejection}; +use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; -use beacon_chain::validator_monitor::timestamp_now; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use bls::PublicKeyBytes; -use eth2::StatusCode; use eth2::types::{ Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, @@ -18,6 +18,7 @@ use eth2::types::{ }; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; +use reqwest::StatusCode; use slot_clock::SlotClock; use std::sync::Arc; use tokio::sync::mpsc::{Sender, UnboundedSender}; @@ -31,6 +32,8 @@ use types::{ use warp::{Filter, Rejection, Reply}; use warp_utils::reject::convert_rejection; +pub mod execution_payload_envelope; + /// Uses the `chain.validator_pubkey_cache` to resolve a pubkey to a validator /// index and then ensures that the validator exists in the given `state`. pub fn pubkey_to_validator_index<T: BeaconChainTypes>( @@ -165,6 +168,42 @@ pub fn post_validator_duties_attester<T: BeaconChainTypes>( .boxed() } +// POST validator/duties/ptc/{epoch} +pub fn post_validator_duties_ptc<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + chain_filter: ChainFilter<T>, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter<T>, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("duties")) + .and(warp::path("ptc")) + .and(warp::path::param::<Epoch>().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid epoch".to_string(), + )) + })) + .and(warp::path::end()) + .and(not_while_syncing_filter.clone()) + .and(warp_utils::json::json()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |epoch: Epoch, + not_synced_filter: Result<(), Rejection>, + indices: ValidatorIndexData, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>| { + task_spawner.blocking_json_task(Priority::P0, move || { + not_synced_filter?; + ptc_duties::ptc_duties(epoch, &indices.0, &chain) + }) + }, + ) + .boxed() +} + // GET validator/aggregate_attestation?attestation_data_root,slot pub fn get_validator_aggregate_attestation<T: BeaconChainTypes>( any_version: AnyVersionFilter, @@ -245,6 +284,106 @@ pub fn get_validator_attestation_data<T: BeaconChainTypes>( .boxed() } +// GET validator/payload_attestation_data/{slot} +pub fn get_validator_payload_attestation_data<T: BeaconChainTypes>( + eth_v1: EthV1Filter, + chain_filter: ChainFilter<T>, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter<T>, +) -> ResponseFilter { + use eth2::beacon_response::{EmptyMetadata, ForkVersionedResponse}; + use ssz::Encode; + use warp::http::Response; + + eth_v1 + .and(warp::path("validator")) + .and(warp::path("payload_attestation_data")) + .and(warp::path::param::<Slot>().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::<Accept>("accept")) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option<Accept>, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner<T::EthSpec>, + chain: Arc<BeaconChain<T>>| { + task_spawner.blocking_response_task(Priority::P0, move || { + not_synced_filter?; + + let fork_name = chain.spec.fork_name_at_slot::<T::EthSpec>(slot); + + // Payload attestations are only valid for Gloas and later forks + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "Payload attestations are not supported for fork: {fork_name}" + ))); + } + + let payload_attestation_data = chain + .produce_payload_attestation_data(slot) + .map_err(|e| match e { + BeaconChainError::InvalidSlot(_) + | BeaconChainError::NoBlockForSlot(_) => { + warp_utils::reject::custom_bad_request(format!( + "Unable to produce payload attestation data: {e:?}" + )) + } + _ => warp_utils::reject::custom_server_error(format!( + "Unable to produce payload attestation data: {e:?}" + )), + })?; + + match accept_header { + Some(Accept::Ssz) => Response::builder() + .status(200) + .header("Content-Type", "application/octet-stream") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body(payload_attestation_data.as_ssz_bytes().into()) + .map(|res: Response<warp::hyper::Body>| res) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build SSZ response: {e}" + )) + }), + _ => { + let json_response = ForkVersionedResponse { + version: fork_name, + metadata: EmptyMetadata {}, + data: payload_attestation_data, + }; + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body( + serde_json::to_string(&json_response) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to serialize response: {e}" + )) + })? + .into(), + ) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build JSON response: {e}" + )) + }) + } + } + }) + }, + ) + .boxed() +} + // GET validator/blinded_blocks/{slot} pub fn get_validator_blinded_blocks<T: BeaconChainTypes>( eth_v1: EthV1Filter, @@ -316,7 +455,11 @@ pub fn get_validator_blocks<T: BeaconChainTypes>( not_synced_filter?; - if endpoint_version == V3 { + // Use V4 block production for Gloas fork + let fork_name = chain.spec.fork_name_at_slot::<T::EthSpec>(slot); + if fork_name.gloas_enabled() { + produce_block_v4(accept_header, chain, slot, query).await + } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await } else { produce_block_v2(accept_header, chain, slot, query).await @@ -662,15 +805,26 @@ pub fn post_validator_prepare_beacon_proposer<T: BeaconChainTypes>( ) .await; - chain - .prepare_beacon_proposer(current_slot) - .await - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!( - "error updating proposer preparations: {:?}", - e - )) - })?; + // TODO(gloas): verify this is correct. We skip proposer preparation for + // Gloas because the execution payload is no longer embedded in the beacon + // block (it's in the payload envelope), so the head block's + // execution_payload() is unavailable. + let next_slot = current_slot + 1; + if !chain + .spec + .fork_name_at_slot::<T::EthSpec>(next_slot) + .gloas_enabled() + { + chain + .prepare_beacon_proposer(current_slot) + .await + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "error updating proposer preparations: {:?}", + e + )) + })?; + } if chain.spec.is_peer_das_scheduled() { let (finalized_beacon_state, _, _) = @@ -708,6 +862,18 @@ pub fn post_validator_prepare_beacon_proposer<T: BeaconChainTypes>( debug!(error = %e, "Could not send message to the network service. \ Likely shutdown") }); + + // Write the updated custody context to disk. This happens at most 128 + // times ever, so the I/O burden should be extremely minimal. Without a + // write here we risk forgetting custody backfill progress upon an + // unclean shutdown. The custody context is otherwise only persisted in + // `BeaconChain::drop`. + if let Err(error) = chain.persist_custody_context() { + error!( + ?error, + "Failed to persist custody context after CGC update" + ); + } } } @@ -840,7 +1006,7 @@ pub fn post_validator_aggregate_and_proofs<T: BeaconChainTypes>( network_tx: UnboundedSender<NetworkMessage<T::EthSpec>>| { task_spawner.blocking_json_task(Priority::P0, move || { not_synced_filter?; - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let mut verified_aggregates = Vec::with_capacity(aggregates.len()); let mut messages = Vec::with_capacity(aggregates.len()); let mut failures = Vec::new(); @@ -862,6 +1028,7 @@ pub fn post_validator_aggregate_and_proofs<T: BeaconChainTypes>( verified_aggregate.aggregate(), verified_aggregate.indexed_attestation(), &chain.slot_clock, + &chain.spec, ); verified_aggregates.push((index, verified_aggregate)); @@ -939,12 +1106,12 @@ pub fn post_validator_aggregate_and_proofs<T: BeaconChainTypes>( // GET validator/duties/proposer/{epoch} pub fn get_validator_duties_proposer<T: BeaconChainTypes>( - eth_v1: EthV1Filter, + any_version: AnyVersionFilter, chain_filter: ChainFilter<T>, not_while_syncing_filter: NotWhileSyncingFilter, task_spawner_filter: TaskSpawnerFilter<T>, ) -> ResponseFilter { - eth_v1 + any_version .and(warp::path("validator")) .and(warp::path("duties")) .and(warp::path("proposer")) @@ -958,13 +1125,20 @@ pub fn get_validator_duties_proposer<T: BeaconChainTypes>( .and(task_spawner_filter) .and(chain_filter) .then( - |epoch: Epoch, + |endpoint_version: EndpointVersion, + epoch: Epoch, not_synced_filter: Result<(), Rejection>, task_spawner: TaskSpawner<T::EthSpec>, chain: Arc<BeaconChain<T>>| { task_spawner.blocking_json_task(Priority::P0, move || { not_synced_filter?; - proposer_duties::proposer_duties(epoch, &chain) + if endpoint_version == V1 { + proposer_duties::proposer_duties(epoch, &chain) + } else if endpoint_version == V2 { + proposer_duties::proposer_duties_v2(epoch, &chain) + } else { + Err(unsupported_version_rejection(endpoint_version)) + } }) }, ) diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 357b78cf41..a380f62ecf 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -4,11 +4,11 @@ use beacon_chain::{ GossipVerifiedBlock, IntoGossipVerifiedBlock, WhenSlotSkipped, test_utils::{AttestationStrategy, BlockStrategy}, }; -use eth2::reqwest::{Response, StatusCode}; use eth2::types::{BroadcastValidation, PublishBlockRequest}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; use http_api::{Config, ProvenancedBlock, publish_blinded_block, publish_block, reconstruct_block}; +use reqwest::{Response, StatusCode}; use std::collections::HashSet; use std::sync::Arc; use types::{ColumnIndex, Epoch, EthSpec, ForkName, Hash256, MainnetEthSpec, Slot}; @@ -85,14 +85,18 @@ pub async fn gossip_invalid() { /* mandated by Beacon API spec */ assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); + // The error depends on whether blobs exist (which affects validation order): + // - Pre-Deneb (no blobs): block validation runs first -> NotFinalizedDescendant + // - Deneb/Electra (blobs): blob validation runs first -> ParentUnknown + // - Fulu+ (columns): block validation runs first -> NotFinalizedDescendant let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -283,13 +287,13 @@ pub async fn consensus_invalid() { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -520,13 +524,13 @@ pub async fn equivocation_invalid() { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -845,16 +849,17 @@ pub async fn blinded_gossip_invalid() { assert!(response.is_err()); let error_response: eth2::Error = response.err().unwrap(); + /* mandated by Beacon API spec */ assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -1070,10 +1075,16 @@ pub async fn blinded_consensus_invalid() { ); } else { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); - assert_server_message_error( - error_response, - format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}"), - ); + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { + format!( + "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" + ) + } else { + format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") + }; + assert_server_message_error(error_response, expected_error_msg); } } @@ -1253,10 +1264,16 @@ pub async fn blinded_equivocation_invalid() { ); } else { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); - assert_server_message_error( - error_response, - format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}"), - ); + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { + format!( + "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" + ) + } else { + format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") + }; + assert_server_message_error(error_response, expected_error_msg); } } @@ -1957,6 +1974,13 @@ pub async fn duplicate_block_status_code() { let validator_count = 64; let num_initial: u64 = 31; let duplicate_block_status_code = StatusCode::IM_A_TEAPOT; + + // Check if deneb is enabled, which is required for blobs. + let spec = test_spec::<E>(); + if !spec.fork_name_at_slot::<E>(Slot::new(0)).deneb_enabled() { + return; + } + let tester = InteractiveTester::<E>::new_with_initializer_and_mutator( None, validator_count, diff --git a/beacon_node/http_api/tests/fork_tests.rs b/beacon_node/http_api/tests/fork_tests.rs index b96c8bd112..4ba35c238c 100644 --- a/beacon_node/http_api/tests/fork_tests.rs +++ b/beacon_node/http_api/tests/fork_tests.rs @@ -404,7 +404,7 @@ async fn bls_to_execution_changes_update_all_around_capella_fork() { bls_withdrawal_credentials(&keypair.pk, spec) } - let header = generate_genesis_header(&spec, true); + let header = generate_genesis_header(&spec); let genesis_state = InteropGenesisBuilder::new() .set_opt_execution_payload_header(header) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index b04c812773..15f61537a0 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -450,13 +450,7 @@ pub async fn proposer_boost_re_org_test( let execution_ctx = mock_el.server.ctx.clone(); let slot_clock = &harness.chain.slot_clock; - // Move to terminal block. mock_el.server.all_payloads_valid(); - execution_ctx - .execution_block_generator - .write() - .move_to_terminal_block() - .unwrap(); // Send proposer preparation data for all validators. let proposer_preparation_data = all_validators @@ -634,7 +628,7 @@ pub async fn proposer_boost_re_org_test( assert_eq!(state_b.slot(), slot_b); let pre_advance_withdrawals = get_expected_withdrawals(&state_b, &harness.chain.spec) .unwrap() - .0 + .withdrawals() .to_vec(); complete_state_advance(&mut state_b, None, slot_c, &harness.chain.spec).unwrap(); @@ -724,7 +718,7 @@ pub async fn proposer_boost_re_org_test( get_expected_withdrawals(&state_b, &harness.chain.spec) } .unwrap() - .0 + .withdrawals() .to_vec(); let payload_attribs_withdrawals = payload_attribs.withdrawals().unwrap(); assert_eq!(expected_withdrawals, *payload_attribs_withdrawals); @@ -981,9 +975,10 @@ async fn proposer_duties_with_gossip_tolerance() { assert_eq!(harness.chain.slot().unwrap(), num_initial); // Set the clock to just before the next epoch. - harness.chain.slot_clock.advance_time( - Duration::from_secs(spec.seconds_per_slot) - spec.maximum_gossip_clock_disparity(), - ); + harness + .chain + .slot_clock + .advance_time(spec.get_slot_duration() - spec.maximum_gossip_clock_disparity()); assert_eq!( harness .chain @@ -1059,6 +1054,241 @@ async fn proposer_duties_with_gossip_tolerance() { ); } +// Test that a request for next epoch v2 proposer duties succeeds when the current slot clock is +// within gossip clock disparity (500ms) of the new epoch. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proposer_duties_v2_with_gossip_tolerance() { + let validator_count = 24; + + let tester = InteractiveTester::<E>::new(None, validator_count).await; + let harness = &tester.harness; + let spec = &harness.spec; + let client = &tester.client; + + let num_initial = 4 * E::slots_per_epoch() - 1; + let next_epoch_start_slot = Slot::new(num_initial + 1); + + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_initial as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::NoValidators, + LightClientStrategy::Disabled, + ) + .await; + + assert_eq!(harness.chain.slot().unwrap(), num_initial); + + // Set the clock to just before the next epoch. + harness + .chain + .slot_clock + .advance_time(spec.get_slot_duration() - spec.maximum_gossip_clock_disparity()); + assert_eq!( + harness + .chain + .slot_clock + .now_with_future_tolerance(spec.maximum_gossip_clock_disparity()) + .unwrap(), + next_epoch_start_slot + ); + + let head_state = harness.get_current_state(); + let head_block_root = harness.head_block_root(); + let tolerant_current_epoch = next_epoch_start_slot.epoch(E::slots_per_epoch()); + + // Prime the proposer shuffling cache with an incorrect entry (regression test). + let wrong_decision_root = head_state + .proposer_shuffling_decision_root(head_block_root, spec) + .unwrap(); + let wrong_proposer_indices = vec![0; E::slots_per_epoch() as usize]; + harness + .chain + .beacon_proposer_cache + .lock() + .insert( + tolerant_current_epoch, + wrong_decision_root, + wrong_proposer_indices.clone(), + head_state.fork(), + ) + .unwrap(); + + // Request the v2 proposer duties. + let proposer_duties_tolerant_current_epoch = client + .get_validator_duties_proposer_v2(tolerant_current_epoch) + .await + .unwrap(); + + assert_eq!( + proposer_duties_tolerant_current_epoch.dependent_root, + head_state + .proposer_shuffling_decision_root_at_epoch( + tolerant_current_epoch, + head_block_root, + spec, + ) + .unwrap() + ); + assert_ne!( + proposer_duties_tolerant_current_epoch + .data + .iter() + .map(|data| data.validator_index as usize) + .collect::<Vec<_>>(), + wrong_proposer_indices, + ); + + // We should get the exact same result after properly advancing into the epoch. + harness + .chain + .slot_clock + .advance_time(spec.maximum_gossip_clock_disparity()); + assert_eq!(harness.chain.slot().unwrap(), next_epoch_start_slot); + let proposer_duties_current_epoch = client + .get_validator_duties_proposer_v2(tolerant_current_epoch) + .await + .unwrap(); + + assert_eq!( + proposer_duties_tolerant_current_epoch, + proposer_duties_current_epoch + ); +} + +// Test that post-Fulu, v1 and v2 proposer duties return different dependent roots. +// Post-Fulu, the true dependent root shifts to the block root at the end of epoch N-2 (due to +// `min_seed_lookahead`), while the legacy v1 root remains at the end of epoch N-1. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proposer_duties_v2_post_fulu_dependent_root() { + type E = MinimalEthSpec; + let spec = test_spec::<E>(); + + if !spec.is_fulu_scheduled() { + return; + } + + let validator_count = 24; + let slots_per_epoch = E::slots_per_epoch(); + + let tester = InteractiveTester::<E>::new(Some(spec.clone()), validator_count).await; + let harness = &tester.harness; + let client = &tester.client; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + mock_el.server.all_payloads_valid(); + + // Build 3 full epochs of chain so we're in epoch 3. + let num_slots = 3 * slots_per_epoch; + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_slots as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::AllValidators, + LightClientStrategy::Disabled, + ) + .await; + + let current_epoch = harness.chain.epoch().unwrap(); + assert_eq!(current_epoch, Epoch::new(3)); + + // For epoch 3 with min_seed_lookahead=1: + // Post-Fulu decision slot: end of epoch N-2 = end of epoch 1 = slot 15 + // Legacy decision slot: end of epoch N-1 = end of epoch 2 = slot 23 + let true_decision_slot = Epoch::new(1).end_slot(slots_per_epoch); + let legacy_decision_slot = Epoch::new(2).end_slot(slots_per_epoch); + assert_eq!(true_decision_slot, Slot::new(15)); + assert_eq!(legacy_decision_slot, Slot::new(23)); + + // Fetch the block roots at these slots to compute expected dependent roots. + let expected_v2_root = harness + .chain + .block_root_at_slot(true_decision_slot, beacon_chain::WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + let expected_v1_root = harness + .chain + .block_root_at_slot(legacy_decision_slot, beacon_chain::WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + + // Sanity check: the two roots should be different since they refer to different blocks. + assert_ne!( + expected_v1_root, expected_v2_root, + "legacy and true decision roots should differ post-Fulu" + ); + + // Query v1 and v2 proposer duties for the current epoch. + let v1_result = client + .get_validator_duties_proposer(current_epoch) + .await + .unwrap(); + let v2_result = client + .get_validator_duties_proposer_v2(current_epoch) + .await + .unwrap(); + + // The proposer assignments (data) must be identical. + assert_eq!(v1_result.data, v2_result.data); + + // The dependent roots must differ. + assert_ne!( + v1_result.dependent_root, v2_result.dependent_root, + "v1 and v2 dependent roots should differ post-Fulu" + ); + + // Verify each root matches the expected value. + assert_eq!( + v1_result.dependent_root, expected_v1_root, + "v1 dependent root should be block root at end of epoch N-1" + ); + assert_eq!( + v2_result.dependent_root, expected_v2_root, + "v2 dependent root should be block root at end of epoch N-2" + ); + + // Also verify the next-epoch path (epoch 4). + let next_epoch = current_epoch + 1; + let v1_next = client + .get_validator_duties_proposer(next_epoch) + .await + .unwrap(); + let v2_next = client + .get_validator_duties_proposer_v2(next_epoch) + .await + .unwrap(); + + assert_eq!(v1_next.data, v2_next.data); + assert_ne!( + v1_next.dependent_root, v2_next.dependent_root, + "v1 and v2 next-epoch dependent roots should differ post-Fulu" + ); + + // For epoch 4: true decision is end of epoch 2 (slot 23), legacy is end of epoch 3 (slot 31). + let expected_v2_next_root = harness + .chain + .block_root_at_slot( + Epoch::new(2).end_slot(slots_per_epoch), + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap(); + let expected_v1_next_root = harness + .chain + .block_root_at_slot( + Epoch::new(3).end_slot(slots_per_epoch), + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(harness.head_block_root()); + assert_eq!(v1_next.dependent_root, expected_v1_next_root); + assert_eq!(v2_next.dependent_root, expected_v2_next_root); + assert_ne!(expected_v2_next_root, harness.head_block_root()); +} + // Test that a request to `lighthouse/custody/backfill` succeeds by verifying that `CustodyContext` and `DataColumnCustodyInfo` // have been updated with the correct values. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/beacon_node/http_api/tests/status_tests.rs b/beacon_node/http_api/tests/status_tests.rs index 556b75cb85..791e643ec4 100644 --- a/beacon_node/http_api/tests/status_tests.rs +++ b/beacon_node/http_api/tests/status_tests.rs @@ -3,9 +3,9 @@ use beacon_chain::{ BlockError, test_utils::{AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy}, }; -use eth2::StatusCode; use execution_layer::{PayloadStatusV1, PayloadStatusV1Status}; use http_api::test_utils::InteractiveTester; +use reqwest::StatusCode; use types::{EthSpec, ExecPayload, ForkName, MinimalEthSpec, Slot, Uint256}; type E = MinimalEthSpec; @@ -21,15 +21,8 @@ async fn post_merge_tester(chain_depth: u64, validator_count: u64) -> Interactiv let tester = InteractiveTester::<E>::new(Some(spec), validator_count as usize).await; let harness = &tester.harness; let mock_el = harness.mock_execution_layer.as_ref().unwrap(); - let execution_ctx = mock_el.server.ctx.clone(); - // Move to terminal block. mock_el.server.all_payloads_valid(); - execution_ctx - .execution_block_generator - .write() - .move_to_terminal_block() - .unwrap(); // Create some chain depth. harness.advance_slot(); diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index defaae5d83..3f933f4a5b 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3,18 +3,19 @@ use beacon_chain::test_utils::RelativeSyncCommittee; use beacon_chain::{ BeaconChain, ChainConfig, StateSkipConfig, WhenSlotSkipped, test_utils::{ - AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, test_spec, + AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, + fork_name_from_env, test_spec, }, }; -use bls::{AggregateSignature, Keypair, PublicKeyBytes, Signature, SignatureBytes}; +use bls::{AggregateSignature, Keypair, PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use eth2::{ BeaconNodeHttpClient, Error, Error::ServerMessage, - StatusCode, Timeouts, + Timeouts, mixin::{RequestAccept, ResponseForkName, ResponseOptional}, - reqwest::{RequestBuilder, Response}, types::{ - BlockId as CoreBlockId, ForkChoiceNode, ProduceBlockV3Response, StateId as CoreStateId, *, + BlockId as CoreBlockId, ForkChoiceNode, ProduceBlockV3Response, ProduceBlockV4Metadata, + StateId as CoreStateId, *, }, }; use execution_layer::expected_gas_limit; @@ -33,10 +34,11 @@ use lighthouse_network::{Enr, PeerId, types::SyncState}; use network::NetworkReceivers; use network_utils::enr_ext::EnrExt; use operation_pool::attestation_storage::CheckpointKey; -use proto_array::ExecutionStatus; +use proto_array::{ExecutionStatus, core::ProtoNode}; +use reqwest::{RequestBuilder, Response, StatusCode}; use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; -use ssz::BitList; +use ssz::{BitList, Decode}; use state_processing::per_block_processing::get_expected_withdrawals; use state_processing::per_slot_processing; use state_processing::state_advance::partial_state_advance; @@ -44,15 +46,16 @@ use std::convert::TryInto; use std::sync::Arc; use tokio::time::Duration; use tree_hash::TreeHash; -use types::application_domain::ApplicationDomain; +use types::ApplicationDomain; use types::{ Domain, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, RelativeEpoch, SelectionProof, - SignedRoot, SingleAttestation, Slot, attestation::AttestationBase, + SignedExecutionPayloadEnvelope, SignedRoot, SingleAttestation, Slot, + attestation::AttestationBase, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; type E = MainnetEthSpec; -const SECONDS_PER_SLOT: u64 = 12; +const SLOT_DURATION_MS: u64 = 12_000; const SLOTS_PER_EPOCH: u64 = 32; const VALIDATOR_COUNT: usize = SLOTS_PER_EPOCH as usize; const CHAIN_LENGTH: u64 = SLOTS_PER_EPOCH * 5 - 1; // Make `next_block` an epoch transition @@ -135,7 +138,7 @@ impl ApiTester { let mut harness = BeaconChainHarness::builder(MainnetEthSpec) .spec(spec.clone()) .chain_config(ChainConfig { - reconstruct_historic_states: config.retain_historic_states, + archive: config.retain_historic_states, ..ChainConfig::default() }) .deterministic_keypairs(VALIDATOR_COUNT) @@ -145,15 +148,6 @@ impl ApiTester { .node_custody_type(config.node_custody_type) .build(); - harness - .mock_execution_layer - .as_ref() - .unwrap() - .server - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - harness.advance_slot(); for _ in 0..CHAIN_LENGTH { @@ -323,7 +317,7 @@ impl ApiTester { let client = BeaconNodeHttpClient::new( beacon_url, - Timeouts::set_all(Duration::from_secs(SECONDS_PER_SLOT)), + Timeouts::set_all(Duration::from_millis(SLOT_DURATION_MS)), ); Self { @@ -411,7 +405,7 @@ impl ApiTester { listening_socket.port() )) .unwrap(), - Timeouts::set_all(Duration::from_secs(SECONDS_PER_SLOT)), + Timeouts::set_all(Duration::from_millis(SLOT_DURATION_MS)), ); Self { @@ -1416,6 +1410,73 @@ impl ApiTester { self } + pub async fn test_beacon_states_proposer_lookahead(self) -> Self { + for state_id in self.interesting_state_ids() { + let mut state_opt = state_id + .state(&self.chain) + .ok() + .map(|(state, _execution_optimistic, _finalized)| state); + + let result = match self + .client + .get_beacon_states_proposer_lookahead(state_id.0) + .await + { + Ok(response) => response, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + if result.is_none() && state_opt.is_none() { + continue; + } + + let state = state_opt.as_mut().expect("result should be none"); + let expected = state.proposer_lookahead().unwrap().to_vec(); + + let response = result.unwrap(); + // Compare Vec<u64> directly, not Vec<String> + assert_eq!(response.data().0, expected); + + // Check that the version header is returned in the response + let fork_name = state.fork_name(&self.chain.spec).unwrap(); + assert_eq!(response.version(), Some(fork_name),); + } + + self + } + + pub async fn test_beacon_states_proposer_lookahead_ssz(self) -> Self { + for state_id in self.interesting_state_ids() { + let mut state_opt = state_id + .state(&self.chain) + .ok() + .map(|(state, _execution_optimistic, _finalized)| state); + + let result = match self + .client + .get_beacon_states_proposer_lookahead_ssz(state_id.0) + .await + { + Ok(response) => response, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + if result.is_none() && state_opt.is_none() { + continue; + } + + let state = state_opt.as_mut().expect("result should be none"); + let expected = state.proposer_lookahead().unwrap(); + + let ssz_bytes = result.unwrap(); + let decoded = Vec::<u64>::from_ssz_bytes(&ssz_bytes) + .expect("should decode SSZ proposer lookahead"); + assert_eq!(decoded, expected.to_vec()); + } + + self + } + pub async fn test_beacon_headers_all_slots(self) -> Self { for slot in 0..CHAIN_LENGTH { let slot = Slot::from(slot); @@ -1970,8 +2031,8 @@ impl ApiTester { .await { Ok(result) => panic!("Full node are unable to return blobs post-Fulu: {result:?}"), - // Post-Fulu, full nodes don't store blobs and return error 500 - Err(e) => assert_eq!(e.status().unwrap(), 500), + // Post-Fulu, full nodes don't store blobs and return error 400 (Bad Request) + Err(e) => assert_eq!(e.status().unwrap(), 400), }; self @@ -2732,6 +2793,89 @@ impl ApiTester { self } + fn make_valid_payload_attestation_message( + &self, + ptc_offset: usize, + ) -> PayloadAttestationMessage { + let head = self.chain.head_snapshot(); + let head_slot = head.beacon_block.slot(); + let head_root = head.beacon_block_root; + let fork = head.beacon_state.fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + let ptc = head + .beacon_state + .get_ptc(head_slot, &self.chain.spec) + .expect("should get PTC"); + + // Find distinct validator indices in the PTC (may contain duplicates due to + // weighted sampling with a small validator set). + let mut seen = std::collections::HashSet::new(); + let distinct_indices: Vec<usize> = ptc + .0 + .iter() + .copied() + .filter(|idx| seen.insert(*idx)) + .collect(); + let validator_index = distinct_indices[ptc_offset % distinct_indices.len()]; + + let data = PayloadAttestationData { + beacon_block_root: head_root, + slot: head_slot, + payload_present: true, + blob_data_available: true, + }; + + let epoch = head_slot.epoch(E::slots_per_epoch()); + let domain = + self.chain + .spec + .get_domain(epoch, Domain::PTCAttester, &fork, genesis_validators_root); + let signing_root = data.signing_root(domain); + let sk = &self.validator_keypairs()[validator_index].sk; + let signature = sk.sign(signing_root); + + PayloadAttestationMessage { + validator_index: validator_index as u64, + data, + signature, + } + } + + pub async fn test_post_beacon_pool_payload_attestations_valid(mut self) -> Self { + let message = self.make_valid_payload_attestation_message(0); + let fork_name = self.chain.spec.fork_name_at_slot::<E>(message.data.slot); + + self.client + .post_beacon_pool_payload_attestations(&[message], fork_name) + .await + .unwrap(); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid payload attestation should be sent to network" + ); + + self + } + + pub async fn test_post_beacon_pool_payload_attestations_valid_ssz(mut self) -> Self { + let message = self.make_valid_payload_attestation_message(1); + let fork_name = self.chain.spec.fork_name_at_slot::<E>(message.data.slot); + + self.client + .post_beacon_pool_payload_attestations_ssz(&[message], fork_name) + .await + .unwrap(); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid payload attestation (SSZ) should be sent to network" + ); + + self + } + pub async fn test_get_config_fork_schedule(self) -> Self { let result = self.client.get_config_fork_schedule().await.unwrap().data; @@ -2855,19 +2999,9 @@ impl ApiTester { let expected = IdentityData { peer_id: self.local_enr.peer_id().to_string(), - enr: self.local_enr.to_base64(), - p2p_addresses: self - .local_enr - .multiaddr_p2p_tcp() - .iter() - .map(|a| a.to_string()) - .collect(), - discovery_addresses: self - .local_enr - .multiaddr_p2p_udp() - .iter() - .map(|a| a.to_string()) - .collect(), + enr: self.local_enr.clone(), + p2p_addresses: self.local_enr.multiaddr_p2p_tcp(), + discovery_addresses: self.local_enr.multiaddr_p2p_udp(), metadata: MetaData::V2(MetaDataV2 { seq_number: 0, attnets: "0x0000000000000000".to_string(), @@ -2896,7 +3030,7 @@ impl ApiTester { pub async fn test_get_node_peers_by_id(self) -> Self { let result = self .client - .get_node_peers_by_id(&self.external_peer_id.to_string()) + .get_node_peers_by_id(self.external_peer_id) .await .unwrap() .data; @@ -3080,51 +3214,65 @@ impl ApiTester { .nodes .iter() .map(|node| { - let execution_status = if node.execution_status.is_execution_enabled() { - Some(node.execution_status.to_string()) + let execution_status = if node + .execution_status() + .is_ok_and(|status| status.is_execution_enabled()) + { + node.execution_status() + .ok() + .map(|status| status.to_string()) } else { None }; ForkChoiceNode { - slot: node.slot, - block_root: node.root, + slot: node.slot(), + block_root: node.root(), parent_root: node - .parent + .parent() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|parent| parent.root), - justified_epoch: node.justified_checkpoint.epoch, - finalized_epoch: node.finalized_checkpoint.epoch, - weight: node.weight, + .map(|parent| parent.root()), + justified_epoch: node.justified_checkpoint().epoch, + finalized_epoch: node.finalized_checkpoint().epoch, + weight: node.weight(), validity: execution_status, execution_block_hash: node - .execution_status - .block_hash() + .execution_status() + .ok() + .and_then(|status| status.block_hash()) .map(|block_hash| block_hash.into_root()), extra_data: ForkChoiceExtraData { - target_root: node.target_root, - justified_root: node.justified_checkpoint.root, - finalized_root: node.finalized_checkpoint.root, + target_root: node.target_root(), + justified_root: node.justified_checkpoint().root, + finalized_root: node.finalized_checkpoint().root, unrealized_justified_root: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_finalized_root: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_justified_epoch: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.epoch), unrealized_finalized_epoch: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.epoch), - execution_status: node.execution_status.to_string(), + execution_status: node + .execution_status() + .ok() + .map(|status| status.to_string()) + .unwrap_or_else(|| "irrelevant".to_string()), best_child: node - .best_child + .best_child() + .ok() + .flatten() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|child| child.root), + .map(|child| child.root()), best_descendant: node - .best_descendant + .best_descendant() + .ok() + .flatten() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|descendant| descendant.root), + .map(|descendant| descendant.root()), }, } }) @@ -3315,17 +3463,20 @@ impl ApiTester { .unwrap() .unwrap_or(self.chain.head_beacon_block_root()); - // Presently, the beacon chain harness never runs the code that primes the proposer - // cache. If this changes in the future then we'll need some smarter logic here, but - // this is succinct and effective for the time being. - assert!( - self.chain - .beacon_proposer_cache - .lock() - .get_epoch::<E>(dependent_root, epoch) - .is_none(), - "the proposer cache should miss initially" - ); + // Block import primes the proposer cache for each epoch it runs through (to gate + // proposer boost), so epochs `<= current_epoch` are already cached. The only epoch + // for which we can observe the endpoint's own caching behaviour is + // `current_epoch + 1`, which no block import has touched yet. + if epoch == current_epoch + 1 { + assert!( + self.chain + .beacon_proposer_cache + .lock() + .get_epoch::<E>(dependent_root, epoch) + .is_none(), + "the proposer cache should miss initially for the next epoch" + ); + } let result = self .client @@ -3333,8 +3484,9 @@ impl ApiTester { .await .unwrap(); - // Check that current-epoch requests prime the proposer cache, whilst non-current - // requests don't. + // A current-epoch request should leave the cache primed (block import already did so, + // but this is still a useful end-to-end check). A request for `current_epoch + 1` + // should not prime the cache. if epoch == current_epoch { assert!( self.chain @@ -3342,16 +3494,16 @@ impl ApiTester { .lock() .get_epoch::<E>(dependent_root, epoch) .is_some(), - "a current-epoch request should prime the proposer cache" + "the proposer cache should be primed for the current epoch" ); - } else { + } else if epoch == current_epoch + 1 { assert!( self.chain .beacon_proposer_cache .lock() .get_epoch::<E>(dependent_root, epoch) .is_none(), - "a non-current-epoch request should not prime the proposer cache" + "a request for the next epoch should not prime the proposer cache" ); } @@ -3422,6 +3574,80 @@ impl ApiTester { self } + pub async fn test_get_validator_duties_proposer_v2(self) -> Self { + let current_epoch = self.chain.epoch().unwrap(); + + for epoch in 0..=current_epoch.as_u64() + 1 { + let epoch = Epoch::from(epoch); + + // Compute the true dependent root using the spec's decision slot. + let decision_slot = self.chain.spec.proposer_shuffling_decision_slot::<E>(epoch); + let dependent_root = self + .chain + .block_root_at_slot(decision_slot, WhenSlotSkipped::Prev) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + let result = self + .client + .get_validator_duties_proposer_v2(epoch) + .await + .unwrap(); + + let mut state = self + .chain + .state_at_slot( + epoch.start_slot(E::slots_per_epoch()), + StateSkipConfig::WithStateRoots, + ) + .unwrap(); + + state + .build_committee_cache(RelativeEpoch::Current, &self.chain.spec) + .unwrap(); + + let expected_duties = epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let index = state + .get_beacon_proposer_index(slot, &self.chain.spec) + .unwrap(); + let pubkey = state.validators().get(index).unwrap().pubkey; + + ProposerData { + pubkey, + validator_index: index as u64, + slot, + } + }) + .collect::<Vec<_>>(); + + let expected = DutiesResponse { + data: expected_duties, + execution_optimistic: Some(false), + dependent_root, + }; + + assert_eq!(result, expected); + + // v1 and v2 should return the same data. + let v1_result = self + .client + .get_validator_duties_proposer(epoch) + .await + .unwrap(); + assert_eq!(result.data, v1_result.data); + } + + // Requests to the epochs after the next epoch should fail. + self.client + .get_validator_duties_proposer_v2(current_epoch + 2) + .await + .unwrap_err(); + + self + } + pub async fn test_get_validator_duties_early(self) -> Self { let current_epoch = self.chain.epoch().unwrap(); let next_epoch = current_epoch + 1; @@ -3471,6 +3697,17 @@ impl ApiTester { "should not get attester duties outside of tolerance" ); + assert_eq!( + self.client + .post_validator_duties_ptc(next_epoch, &[0]) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400), + "should not get ptc duties outside of tolerance" + ); + self.chain.slot_clock.set_current_time( current_epoch_start - self.chain.spec.maximum_gossip_clock_disparity(), ); @@ -3494,6 +3731,88 @@ impl ApiTester { .await .expect("should get attester duties within tolerance"); + self.client + .post_validator_duties_ptc(next_epoch, &[0]) + .await + .expect("should get ptc duties within tolerance"); + + self + } + + pub async fn test_get_validator_duties_ptc(self) -> Self { + let current_epoch = self.chain.epoch().unwrap().as_u64(); + + let half = current_epoch / 2; + let first = current_epoch - half; + let last = current_epoch + half; + + for epoch in first..=last { + for indices in self.interesting_validator_indices() { + let epoch = Epoch::from(epoch); + + // The endpoint does not allow getting duties past the next epoch. + if epoch > current_epoch + 1 { + assert_eq!( + self.client + .post_validator_duties_ptc(epoch, indices.as_slice()) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400) + ); + continue; + } + + let results = self + .client + .post_validator_duties_ptc(epoch, indices.as_slice()) + .await + .unwrap(); + + let dependent_root = self + .chain + .block_root_at_slot( + (epoch - 1).start_slot(E::slots_per_epoch()) - 1, + WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + assert_eq!(results.dependent_root, dependent_root); + + let result_duties = results.data; + + let state = self + .chain + .state_at_slot( + epoch.start_slot(E::slots_per_epoch()), + StateSkipConfig::WithStateRoots, + ) + .unwrap(); + + let expected_duties: Vec<PtcDuty> = indices + .iter() + .filter_map(|&validator_index| { + let validator = state.validators().get(validator_index as usize)?; + let slot = state + .get_ptc_assignment(validator_index as usize, epoch, &self.chain.spec) + .unwrap()?; + Some(PtcDuty { + pubkey: validator.pubkey, + validator_index, + slot, + }) + }) + .collect(); + + assert_eq!( + result_duties, expected_duties, + "ptc duties should exactly match state assignments" + ); + } + } + self } @@ -3749,6 +4068,228 @@ impl ApiTester { self } + /// Get the proposer secret key and randao reveal for the given slot. + async fn proposer_setup( + &self, + slot: Slot, + epoch: Epoch, + fork: &Fork, + genesis_validators_root: Hash256, + ) -> (SecretKey, SignatureBytes) { + let proposer_pubkey_bytes = self + .client + .get_validator_duties_proposer(epoch) + .await + .unwrap() + .data + .into_iter() + .find(|duty| duty.slot == slot) + .map(|duty| duty.pubkey) + .unwrap(); + let proposer_pubkey = (&proposer_pubkey_bytes).try_into().unwrap(); + + let sk = self + .validator_keypairs() + .iter() + .find(|kp| kp.pk == proposer_pubkey) + .map(|kp| kp.sk.clone()) + .unwrap(); + + let randao_reveal = { + let domain = + self.chain + .spec + .get_domain(epoch, Domain::Randao, fork, genesis_validators_root); + let message = epoch.signing_root(domain); + sk.sign(message).into() + }; + + (sk, randao_reveal) + } + + /// Assert block metadata and verify the envelope cache. + fn assert_v4_block_metadata( + &self, + block: &BeaconBlock<E>, + metadata: &ProduceBlockV4Metadata, + slot: Slot, + ) { + assert_eq!( + metadata.consensus_version, + block.to_ref().fork_name(&self.chain.spec).unwrap() + ); + assert!(!metadata.consensus_block_value.is_zero()); + + let block_root = block.tree_hash_root(); + let envelope = self + .chain + .pending_payload_envelopes + .read() + .get(slot) + .cloned() + .expect("envelope should exist in pending cache for local building"); + assert_eq!(envelope.beacon_block_root, block_root); + assert_eq!(envelope.slot(), slot); + } + + /// Assert envelope fields match the expected block root and slot. + fn assert_envelope_fields( + &self, + envelope: &ExecutionPayloadEnvelope<E>, + block_root: Hash256, + slot: Slot, + ) { + assert_eq!(envelope.beacon_block_root, block_root); + assert_eq!(envelope.slot(), slot); + assert_eq!(envelope.builder_index, BUILDER_INDEX_SELF_BUILD); + } + + /// Sign an execution payload envelope. + fn sign_envelope( + &self, + envelope: ExecutionPayloadEnvelope<E>, + sk: &SecretKey, + epoch: Epoch, + fork: &Fork, + genesis_validators_root: Hash256, + ) -> SignedExecutionPayloadEnvelope<E> { + let domain = + self.chain + .spec + .get_domain(epoch, Domain::BeaconBuilder, fork, genesis_validators_root); + let signing_root = envelope.signing_root(domain); + let signature = sk.sign(signing_root); + + SignedExecutionPayloadEnvelope { + message: envelope, + signature, + } + } + + /// Test V4 block production (JSON). Only runs if Gloas is scheduled. + pub async fn test_block_production_v4(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::<E>(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (response, metadata) = self + .client + .get_validator_blocks_v4::<E>(slot, &randao_reveal, None, None, None) + .await + .unwrap(); + let block = response.data; + + self.assert_v4_block_metadata(&block, &metadata, slot); + + let envelope = self + .client + .get_validator_execution_payload_envelope::<E>(slot, BUILDER_INDEX_SELF_BUILD) + .await + .unwrap() + .data; + + self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); + self.client + .post_beacon_blocks_v2(&signed_block_request, None) + .await + .unwrap(); + assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelope(&signed_envelope, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + + /// Test V4 block production (SSZ). Only runs if Gloas is scheduled. + pub async fn test_block_production_v4_ssz(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::<E>(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (block, metadata) = self + .client + .get_validator_blocks_v4_ssz::<E>(slot, &randao_reveal, None, None, None) + .await + .unwrap(); + + self.assert_v4_block_metadata(&block, &metadata, slot); + + let envelope = self + .client + .get_validator_execution_payload_envelope_ssz::<E>(slot, BUILDER_INDEX_SELF_BUILD) + .await + .unwrap(); + + self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); + self.client + .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .await + .unwrap(); + assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelope_ssz(&signed_envelope, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + pub async fn test_block_production_no_verify_randao(self) -> Self { for _ in 0..E::slots_per_epoch() { let slot = self.chain.slot().unwrap(); @@ -4085,6 +4626,53 @@ impl ApiTester { self } + pub async fn test_get_validator_payload_attestation_data(self) -> Self { + let slot = self.chain.slot().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::<E>(slot); + + let response = self + .client + .get_validator_payload_attestation_data(slot) + .await + .unwrap(); + + assert_eq!(response.version(), Some(fork_name)); + + let result = response.into_data(); + let expected = self.chain.produce_payload_attestation_data(slot).unwrap(); + + assert_eq!(result.beacon_block_root, expected.beacon_block_root); + assert_eq!(result.slot, expected.slot); + assert_eq!(result.payload_present, expected.payload_present); + assert_eq!(result.blob_data_available, expected.blob_data_available); + + let ssz_result = self + .client + .get_validator_payload_attestation_data_ssz(slot) + .await + .unwrap(); + + assert_eq!(ssz_result, expected); + + self + } + + pub async fn test_get_validator_payload_attestation_data_pre_gloas(self) -> Self { + let slot = self.chain.slot().unwrap(); + + // The endpoint should return a 400 error for pre-Gloas forks + match self + .client + .get_validator_payload_attestation_data(slot) + .await + { + Ok(result) => panic!("query for pre-Gloas slot should fail, got: {result:?}"), + Err(e) => assert_eq!(e.status().unwrap(), 400), + } + + self + } + #[allow(clippy::await_holding_lock)] // This is a test, so it should be fine. pub async fn test_get_validator_aggregate_attestation_v1(self) -> Self { let attestation = self @@ -4397,7 +4985,7 @@ impl ApiTester { .beacon_state .validators() .into_iter() - .zip(fee_recipients.into_iter()) + .zip(fee_recipients) .enumerate() { let actual_fee_recipient = self @@ -4454,7 +5042,7 @@ impl ApiTester { .beacon_state .validators() .into_iter() - .zip(fee_recipients.into_iter()) + .zip(fee_recipients) .enumerate() { let actual = self @@ -4493,7 +5081,7 @@ impl ApiTester { .beacon_state .validators() .into_iter() - .zip(fee_recipients.into_iter()) + .zip(fee_recipients) .enumerate() { let actual_fee_recipient = self @@ -6552,8 +7140,13 @@ impl ApiTester { block_events.as_slice(), &[ expected_gossip, - expected_block, + // SSE `Head`` event is now emitted before `Block` event, because we only emit the block event + // after it's persisted to the database. We could consider changing this later, but + // we might have to serve http API requests for blocks from early_attester_cache + // before they're persisted to the database. + // https://github.com/sigp/lighthouse/pull/8718#issuecomment-3815593310 expected_head, + expected_block, expected_finalized ] ); @@ -6668,7 +7261,8 @@ impl ApiTester { } let expected_withdrawals = get_expected_withdrawals(&state, &self.chain.spec) .unwrap() - .0; + .withdrawals() + .to_vec(); // fetch expected withdrawals from the client let result = self.client.get_expected_withdrawals(&state_id).await; @@ -6676,7 +7270,7 @@ impl ApiTester { Ok(withdrawal_response) => { assert_eq!(withdrawal_response.execution_optimistic, Some(false)); assert_eq!(withdrawal_response.finalized, Some(false)); - assert_eq!(withdrawal_response.data, expected_withdrawals.to_vec()); + assert_eq!(withdrawal_response.data, expected_withdrawals); } Err(_) => { panic!("query failed incorrectly"); @@ -6826,7 +7420,12 @@ impl ApiTester { .unwrap(); let block_events = poll_events(&mut events_future, 2, Duration::from_millis(10000)).await; - assert_eq!(block_events.as_slice(), &[expected_block, expected_head]); + // SSE `Head`` event is now emitted before `Block` event, because we only emit the block event + // after it's persisted to the database. We could consider changing this later, but + // we might have to serve http API requests for blocks from early_attester_cache + // before they're persisted to the database. + // https://github.com/sigp/lighthouse/pull/8718#issuecomment-3815593310 + assert_eq!(block_events.as_slice(), &[expected_head, expected_block]); self } @@ -6851,6 +7450,7 @@ impl ApiTester { .core_proto_array_mut() .nodes .last_mut() + && let ProtoNode::V17(head_node) = head_node { head_node.execution_status = ExecutionStatus::Optimistic(ExecutionBlockHash::zero()) } @@ -6866,15 +7466,16 @@ impl ApiTester { assert_eq!(result.execution_optimistic, Some(true)); } - async fn test_get_beacon_rewards_blocks_at_head(&self) -> StandardBlockReward { + async fn test_get_beacon_rewards_blocks_at_head( + &self, + ) -> ExecutionOptimisticFinalizedResponse<StandardBlockReward> { self.client .get_beacon_rewards_blocks(CoreBlockId::Head) .await .unwrap() - .data } - async fn test_beacon_block_rewards_electra(self) -> Self { + async fn test_beacon_block_rewards_fulu(self) -> Self { for _ in 0..E::slots_per_epoch() { let state = self.harness.get_current_state(); let slot = state.slot() + Slot::new(1); @@ -6888,8 +7489,80 @@ impl ApiTester { .compute_beacon_block_reward(signed_block.message(), &mut state) .unwrap(); self.harness.extend_slots(1).await; - let api_beacon_block_reward = self.test_get_beacon_rewards_blocks_at_head().await; - assert_eq!(beacon_block_reward, api_beacon_block_reward); + let response = self.test_get_beacon_rewards_blocks_at_head().await; + assert_eq!(response.execution_optimistic, Some(false)); + assert_eq!(response.finalized, Some(false)); + assert_eq!(beacon_block_reward, response.data); + } + self + } + + async fn test_get_beacon_rewards_sync_committee_at_head( + &self, + ) -> ExecutionOptimisticFinalizedResponse<Vec<SyncCommitteeReward>> { + self.client + .post_beacon_rewards_sync_committee(CoreBlockId::Head, &[]) + .await + .unwrap() + } + + async fn test_beacon_sync_committee_rewards_fulu(self) -> Self { + for _ in 0..E::slots_per_epoch() { + let state = self.harness.get_current_state(); + let slot = state.slot() + Slot::new(1); + + let ((signed_block, _maybe_blob_sidecars), mut state) = + self.harness.make_block_return_pre_state(state, slot).await; + + let mut expected_rewards = self + .harness + .chain + .compute_sync_committee_rewards(signed_block.message(), &mut state) + .unwrap(); + expected_rewards.sort_by_key(|r| r.validator_index); + + self.harness.extend_slots(1).await; + + let response = self.test_get_beacon_rewards_sync_committee_at_head().await; + assert_eq!(response.execution_optimistic, Some(false)); + assert_eq!(response.finalized, Some(false)); + let mut api_rewards = response.data; + api_rewards.sort_by_key(|r| r.validator_index); + assert_eq!(expected_rewards, api_rewards); + } + self + } + + async fn test_get_beacon_rewards_attestations( + &self, + epoch: Epoch, + ) -> ExecutionOptimisticFinalizedResponse<StandardAttestationRewards> { + self.client + .post_beacon_rewards_attestations(epoch, &[]) + .await + .unwrap() + } + + async fn test_beacon_attestation_rewards_fulu(self) -> Self { + // Check 3 epochs. + let num_epochs = 3; + for _ in 0..num_epochs { + self.harness + .extend_slots(E::slots_per_epoch() as usize) + .await; + + let epoch = self.chain.epoch().unwrap() - 1; + + let expected_rewards = self + .harness + .chain + .compute_attestation_rewards(epoch, vec![]) + .unwrap(); + + let response = self.test_get_beacon_rewards_attestations(epoch).await; + assert_eq!(response.execution_optimistic, Some(false)); + assert_eq!(response.finalized, Some(false)); + assert_eq!(expected_rewards, response.data); } self } @@ -7098,6 +7771,23 @@ async fn beacon_get_state_info_electra() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn beacon_get_state_info_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_states_proposer_lookahead() + .await + .test_beacon_states_proposer_lookahead_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn beacon_get_blocks() { ApiTester::new() @@ -7389,6 +8079,9 @@ async fn get_light_client_finality_update() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_duties_early() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } ApiTester::new() .await .test_get_validator_duties_early() @@ -7429,6 +8122,54 @@ async fn get_validator_duties_proposer_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_proposer_v2() { + ApiTester::new_from_config(ApiTesterConfig { + spec: test_spec::<E>(), + retain_historic_states: true, + ..ApiTesterConfig::default() + }) + .await + .test_get_validator_duties_proposer_v2() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_proposer_v2_with_skip_slots() { + ApiTester::new_from_config(ApiTesterConfig { + spec: test_spec::<E>(), + retain_historic_states: true, + ..ApiTesterConfig::default() + }) + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_proposer_v2() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_ptc() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_get_validator_duties_ptc() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_ptc_with_skip_slots() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_ptc() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production() { ApiTester::new().await.test_block_production().await; @@ -7487,6 +8228,22 @@ async fn block_production_v3_ssz_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn block_production_v4() { + ApiTester::new_with_hard_forks() + .await + .test_block_production_v4() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn block_production_v4_ssz() { + ApiTester::new_with_hard_forks() + .await + .test_block_production_v4_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn blinded_block_production_full_payload_premerge() { ApiTester::new().await.test_blinded_block_production().await; @@ -7581,6 +8338,43 @@ async fn get_validator_attestation_data_with_skip_slots() { .await; } +// TODO(EIP-7732): Remove `#[ignore]` once gloas beacon chain harness is implemented +#[ignore] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_payload_attestation_data() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new() + .await + .test_get_validator_payload_attestation_data() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_payload_attestation_data_pre_gloas() { + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new() + .await + .test_get_validator_payload_attestation_data_pre_gloas() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_beacon_pool_payload_attestations_valid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new() + .await + .test_post_beacon_pool_payload_attestations_valid() + .await + .test_post_beacon_pool_payload_attestations_valid_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_aggregate_attestation_v1() { ApiTester::new() @@ -8147,16 +8941,47 @@ async fn expected_withdrawals_valid_capella() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_beacon_rewards_blocks_electra() { +async fn get_beacon_rewards_blocks_fulu() { let mut config = ApiTesterConfig::default(); config.spec.altair_fork_epoch = Some(Epoch::new(0)); config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); config.spec.capella_fork_epoch = Some(Epoch::new(0)); config.spec.deneb_fork_epoch = Some(Epoch::new(0)); config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); ApiTester::new_from_config(config) .await - .test_beacon_block_rewards_electra() + .test_beacon_block_rewards_fulu() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_beacon_rewards_sync_committee_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_sync_committee_rewards_fulu() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_beacon_rewards_attestations_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_attestation_rewards_fulu() .await; } diff --git a/beacon_node/lighthouse_network/Cargo.toml b/beacon_node/lighthouse_network/Cargo.toml index efb6f27dc5..44af8d7006 100644 --- a/beacon_node/lighthouse_network/Cargo.toml +++ b/beacon_node/lighthouse_network/Cargo.toml @@ -5,9 +5,6 @@ authors = ["Sigma Prime <contact@sigmaprime.io>"] edition = { workspace = true } autotests = false -[features] -libp2p-websocket = [] - [dependencies] alloy-primitives = { workspace = true } alloy-rlp = { workspace = true } @@ -24,19 +21,21 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } fnv = { workspace = true } futures = { workspace = true } -gossipsub = { workspace = true } +# Enable partial messages feature +gossipsub = { package = "libp2p-gossipsub", git = "https://github.com/libp2p/rust-libp2p.git", features = ["partial_messages"] } hex = { workspace = true } +if-addrs = "0.14" itertools = { workspace = true } -libp2p-mplex = "0.43" +libp2p = { workspace = true } +libp2p-mplex = { git = "https://github.com/libp2p/rust-libp2p.git" } lighthouse_version = { workspace = true } -local-ip-address = "0.6" logging = { workspace = true } lru = { workspace = true } lru_cache = { workspace = true } metrics = { workspace = true } network_utils = { workspace = true } parking_lot = { workspace = true } -prometheus-client = "0.23.0" +prometheus-client = "0.24.0" rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } @@ -55,24 +54,6 @@ typenum = { workspace = true } types = { workspace = true } unsigned-varint = { version = "0.8", features = ["codec"] } -[dependencies.libp2p] -version = "0.56" -default-features = false -features = [ - "identify", - "yamux", - "noise", - "dns", - "tcp", - "tokio", - "plaintext", - "secp256k1", - "macros", - "metrics", - "quic", - "upnp", -] - [dev-dependencies] async-channel = { workspace = true } logging = { workspace = true } diff --git a/beacon_node/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index 416ca73e08..db42d0cfa8 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -5,8 +5,8 @@ use crate::{Enr, PeerIdSerialized}; use directory::{ DEFAULT_BEACON_NODE_DIR, DEFAULT_HARDCODED_NETWORK, DEFAULT_NETWORK_DIR, DEFAULT_ROOT_DIR, }; -use libp2p::Multiaddr; -use local_ip_address::local_ipv6; +use if_addrs::get_if_addrs; +use libp2p::{Multiaddr, gossipsub}; use network_utils::listen_addr::{ListenAddr, ListenAddress}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -140,6 +140,9 @@ pub struct Config { /// Flag for advertising a fake CGC to peers for testing ONLY. pub advertise_false_custody_group_count: Option<u64>, + + /// Whether to enable partial data column support. + pub enable_partial_columns: bool, } impl Config { @@ -262,13 +265,13 @@ impl Config { /// A helper function to check if the local host has a globally routeable IPv6 address. If so, /// returns true. pub fn is_ipv6_supported() -> bool { - // If IPv6 is supported - let Ok(std::net::IpAddr::V6(local_ip)) = local_ipv6() else { + let Ok(addrs) = get_if_addrs() else { return false; }; - // If its globally routable, return true - is_global_ipv6(&local_ip) + addrs.iter().any( + |iface| matches!(iface.addr, if_addrs::IfAddr::V6(ref v6) if is_global_ipv6(&v6.ip)), + ) } pub fn listen_addrs(&self) -> &ListenAddress { @@ -364,6 +367,7 @@ impl Default for Config { inbound_rate_limiter_config: None, idontwant_message_size_threshold: DEFAULT_IDONTWANT_MESSAGE_SIZE_THRESHOLD, advertise_false_custody_group_count: None, + enable_partial_columns: false, } } } @@ -443,7 +447,7 @@ pub fn gossipsub_config( network_load: u8, fork_context: Arc<ForkContext>, gossipsub_config_params: GossipsubConfigParams, - seconds_per_slot: u64, + slot_duration: Duration, slots_per_epoch: u64, idontwant_message_size_threshold: usize, ) -> gossipsub::Config { @@ -487,7 +491,7 @@ pub fn gossipsub_config( // To accommodate the increase, we should increase the duplicate cache time to filter older seen messages. // 2 epochs is quite sane for pre-deneb network parameters as well. // Hence we keep the same parameters for pre-deneb networks as well to avoid switching at the fork. - let duplicate_cache_time = Duration::from_secs(slots_per_epoch * seconds_per_slot * 2); + let duplicate_cache_time = Duration::from_secs(slots_per_epoch * slot_duration.as_secs() * 2); gossipsub::ConfigBuilder::default() .max_transmit_size(gossipsub_config_params.gossipsub_max_transmit_size) diff --git a/beacon_node/lighthouse_network/src/discovery/enr.rs b/beacon_node/lighthouse_network/src/discovery/enr.rs index 4c285ea86c..01a01d55ab 100644 --- a/beacon_node/lighthouse_network/src/discovery/enr.rs +++ b/beacon_node/lighthouse_network/src/discovery/enr.rs @@ -200,11 +200,23 @@ pub fn build_enr<E: EthSpec>( builder.ip6(*ip); } - if let Some(udp4_port) = config.enr_udp4_port { + // If the ENR port is not set, and we are listening over that ip version, use the listening + // discovery port instead. + if let Some(udp4_port) = config.enr_udp4_port.or_else(|| { + config + .listen_addrs() + .v4() + .and_then(|v4_addr| v4_addr.disc_port.try_into().ok()) + }) { builder.udp4(udp4_port.get()); } - if let Some(udp6_port) = config.enr_udp6_port { + if let Some(udp6_port) = config.enr_udp6_port.or_else(|| { + config + .listen_addrs() + .v6() + .and_then(|v6_addr| v6_addr.disc_port.try_into().ok()) + }) { builder.udp6(udp6_port.get()); } diff --git a/beacon_node/lighthouse_network/src/discovery/mod.rs b/beacon_node/lighthouse_network/src/discovery/mod.rs index a8c87523a5..21b1146aff 100644 --- a/beacon_node/lighthouse_network/src/discovery/mod.rs +++ b/beacon_node/lighthouse_network/src/discovery/mod.rs @@ -51,7 +51,7 @@ use types::{ChainSpec, EnrForkId, EthSpec}; mod subnet_predicate; use crate::discovery::enr::{NEXT_FORK_DIGEST_ENR_KEY, PEERDAS_CUSTODY_GROUP_COUNT_ENR_KEY}; pub use subnet_predicate::subnet_predicate; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; /// Local ENR storage filename. pub const ENR_FILENAME: &str = "enr.dat"; @@ -264,47 +264,62 @@ impl<E: EthSpec> Discovery<E> { info!("Contacting Multiaddr boot-nodes for their ENR"); } - // get futures for requesting the Enrs associated to these multiaddr and wait for their + // get futures for requesting the ENRs associated to these multiaddr and wait for their // completion - let mut fut_coll = config + let discv5_eligible_addrs = config .boot_nodes_multiaddr .iter() - .map(|addr| addr.to_string()) - // request the ENR for this multiaddr and keep the original for logging - .map(|addr| { - futures::future::join( - discv5.request_enr(addr.clone()), - futures::future::ready(addr), - ) - }) - .collect::<FuturesUnordered<_>>(); + // Filter out multiaddrs without UDP or P2P protocols required for discv5 ENR requests + .filter(|addr| { + addr.iter().any(|proto| matches!(proto, Protocol::Udp(_))) + && addr.iter().any(|proto| matches!(proto, Protocol::P2p(_))) + }); - while let Some((result, original_addr)) = fut_coll.next().await { - match result { - Ok(enr) => { - debug!( - node_id = %enr.node_id(), - peer_id = %enr.peer_id(), - ip4 = ?enr.ip4(), - udp4 = ?enr.udp4(), - tcp4 = ?enr.tcp4(), - quic4 = ?enr.quic4(), - "Adding node to routing table" - ); - let _ = discv5.add_enr(enr).map_err(|e| { - error!( - addr = original_addr.to_string(), - error = e.to_string(), - "Could not add peer to the local routing table" - ) - }); - } - Err(e) => { - error!( - multiaddr = original_addr.to_string(), - error = e.to_string(), - "Error getting mapping to ENR" + if config.disable_discovery { + if discv5_eligible_addrs.count() > 0 { + warn!( + "Boot node multiaddrs requiring discv5 ENR lookup will be ignored because discovery is disabled" + ); + } + } else { + let mut fut_coll = discv5_eligible_addrs + .map(|addr| addr.to_string()) + // request the ENR for this multiaddr and keep the original for logging + .map(|addr| { + futures::future::join( + discv5.request_enr(addr.clone()), + futures::future::ready(addr), ) + }) + .collect::<FuturesUnordered<_>>(); + + while let Some((result, original_addr)) = fut_coll.next().await { + match result { + Ok(enr) => { + debug!( + node_id = %enr.node_id(), + peer_id = %enr.peer_id(), + ip4 = ?enr.ip4(), + udp4 = ?enr.udp4(), + tcp4 = ?enr.tcp4(), + quic4 = ?enr.quic4(), + "Adding node to routing table" + ); + let _ = discv5.add_enr(enr).map_err(|e| { + error!( + addr = original_addr.to_string(), + error = e.to_string(), + "Could not add peer to the local routing table" + ) + }); + } + Err(e) => { + error!( + multiaddr = original_addr.to_string(), + error = e.to_string(), + "Error getting mapping to ENR" + ) + } } } } @@ -659,7 +674,7 @@ impl<E: EthSpec> Discovery<E> { /// updates the min_ttl field. fn add_subnet_query(&mut self, subnet: Subnet, min_ttl: Option<Instant>, retries: usize) { // remove the entry and complete the query if greater than the maximum search count - if retries > MAX_DISCOVERY_RETRY { + if retries >= MAX_DISCOVERY_RETRY { debug!("Subnet peer discovery did not find sufficient peers. Reached max retry limit"); return; } diff --git a/beacon_node/lighthouse_network/src/discovery/subnet_predicate.rs b/beacon_node/lighthouse_network/src/discovery/subnet_predicate.rs index 6e841c25a5..757dbb5853 100644 --- a/beacon_node/lighthouse_network/src/discovery/subnet_predicate.rs +++ b/beacon_node/lighthouse_network/src/discovery/subnet_predicate.rs @@ -4,7 +4,7 @@ use crate::types::{EnrAttestationBitfield, EnrSyncCommitteeBitfield}; use std::ops::Deref; use tracing::trace; use types::ChainSpec; -use types::data_column_custody_group::compute_subnets_for_node; +use types::data::compute_subnets_for_node; /// Returns the predicate for a given subnet. pub fn subnet_predicate<E>( diff --git a/beacon_node/lighthouse_network/src/lib.rs b/beacon_node/lighthouse_network/src/lib.rs index 3d96a08357..fdb6ff095e 100644 --- a/beacon_node/lighthouse_network/src/lib.rs +++ b/beacon_node/lighthouse_network/src/lib.rs @@ -99,7 +99,7 @@ impl std::fmt::Display for ClearDialError<'_> { pub use crate::types::{ Enr, EnrSyncCommitteeBitfield, GossipTopic, NetworkGlobals, PubsubMessage, Subnet, - SubnetDiscovery, + SubnetDiscovery, decode_partial, }; pub use prometheus_client; @@ -107,8 +107,8 @@ pub use prometheus_client; pub use config::Config as NetworkConfig; pub use discovery::Eth2Enr; pub use discv5; -pub use gossipsub::{IdentTopic, MessageAcceptance, MessageId, Topic, TopicHash}; pub use libp2p; +pub use libp2p::gossipsub::{IdentTopic, MessageAcceptance, MessageId, Topic, TopicHash}; pub use libp2p::{Multiaddr, identity, multiaddr}; pub use libp2p::{PeerId, Swarm, core::ConnectedPoint}; pub use peer_manager::{ diff --git a/beacon_node/lighthouse_network/src/metrics.rs b/beacon_node/lighthouse_network/src/metrics.rs index 623d43a727..d5d1ed5053 100644 --- a/beacon_node/lighthouse_network/src/metrics.rs +++ b/beacon_node/lighthouse_network/src/metrics.rs @@ -83,6 +83,14 @@ pub static FAILED_PUBLISHES_PER_MAIN_TOPIC: LazyLock<Result<IntGaugeVec>> = Lazy &["topic_hash"], ) }); +pub static FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC: LazyLock<Result<IntGaugeVec>> = + LazyLock::new(|| { + try_create_int_gauge_vec( + "gossipsub_failed_partial_publishes_per_main_topic", + "Failed gossip partial message publishes", + &["topic_hash"], + ) + }); pub static TOTAL_RPC_ERRORS_PER_CLIENT: LazyLock<Result<IntCounterVec>> = LazyLock::new(|| { try_create_int_counter_vec( "libp2p_rpc_errors_per_client", diff --git a/beacon_node/lighthouse_network/src/peer_manager/mod.rs b/beacon_node/lighthouse_network/src/peer_manager/mod.rs index 3cfe2b3c3b..d7285c5c8e 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/mod.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/mod.rs @@ -33,9 +33,7 @@ pub use peerdb::sync_status::{SyncInfo, SyncStatus}; use std::collections::{HashMap, HashSet, hash_map::Entry}; use std::net::IpAddr; use strum::IntoEnumIterator; -use types::data_column_custody_group::{ - CustodyIndex, compute_subnets_from_custody_group, get_custody_groups, -}; +use types::data::{CustodyIndex, compute_subnets_from_custody_group, get_custody_groups}; /// Unified peer subnet information structure for pruning logic. struct PeerSubnetInfo<E: EthSpec> { @@ -592,6 +590,8 @@ impl<E: EthSpec> PeerManager<E> { Protocol::BlocksByRange => PeerAction::MidToleranceError, Protocol::BlocksByRoot => PeerAction::MidToleranceError, Protocol::BlobsByRange => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRange => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRoot => PeerAction::MidToleranceError, // Lighthouse does not currently make light client requests; therefore, this // is an unexpected scenario. We do not ban the peer for rate limiting. Protocol::LightClientBootstrap => return, @@ -617,6 +617,8 @@ impl<E: EthSpec> PeerManager<E> { Protocol::Ping => PeerAction::Fatal, Protocol::BlocksByRange => return, Protocol::BlocksByRoot => return, + Protocol::PayloadEnvelopesByRange => return, + Protocol::PayloadEnvelopesByRoot => return, Protocol::BlobsByRange => return, Protocol::BlobsByRoot => return, Protocol::DataColumnsByRoot => return, @@ -640,6 +642,8 @@ impl<E: EthSpec> PeerManager<E> { Protocol::Ping => PeerAction::LowToleranceError, Protocol::BlocksByRange => PeerAction::MidToleranceError, Protocol::BlocksByRoot => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRange => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRoot => PeerAction::MidToleranceError, Protocol::BlobsByRange => PeerAction::MidToleranceError, Protocol::BlobsByRoot => PeerAction::MidToleranceError, Protocol::DataColumnsByRoot => PeerAction::MidToleranceError, @@ -3083,6 +3087,9 @@ mod tests { const MAX_TEST_PEERS: usize = 300; proptest! { + // 64 cases (down from default 256) keeps this test under 10s while + // still providing good random coverage of the pruning logic. + #![proptest_config(ProptestConfig::with_cases(64))] #[test] fn prune_excess_peers(peer_conditions in proptest::collection::vec(peer_condition_strategy(), DEFAULT_TARGET_PEERS..=MAX_TEST_PEERS)) { let target_peer_count = DEFAULT_TARGET_PEERS; diff --git a/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs b/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs index 87337cafcf..11ce785350 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/peerdb.rs @@ -15,7 +15,7 @@ use std::{ }; use sync_status::SyncStatus; use tracing::{debug, error, trace, warn}; -use types::data_column_custody_group::compute_subnets_for_node; +use types::data::compute_subnets_for_node; use types::{ChainSpec, DataColumnSubnetId, Epoch, EthSpec, Hash256, Slot}; pub mod client; @@ -259,10 +259,10 @@ impl<E: EthSpec> PeerDB<E> { info.is_connected() && match info.sync_status() { SyncStatus::Synced { info } => { - info.has_slot(epoch.end_slot(E::slots_per_epoch())) + info.has_slot(epoch.start_slot(E::slots_per_epoch())) } SyncStatus::Advanced { info } => { - info.has_slot(epoch.end_slot(E::slots_per_epoch())) + info.has_slot(epoch.start_slot(E::slots_per_epoch())) } SyncStatus::IrrelevantPeer | SyncStatus::Behind { .. } @@ -332,7 +332,7 @@ impl<E: EthSpec> PeerDB<E> { info.is_connected() && match info.sync_status() { SyncStatus::Synced { info } | SyncStatus::Advanced { info } => { - info.has_slot(epoch.end_slot(E::slots_per_epoch())) + info.has_slot(epoch.start_slot(E::slots_per_epoch())) } SyncStatus::IrrelevantPeer | SyncStatus::Behind { .. } diff --git a/beacon_node/lighthouse_network/src/rpc/codec.rs b/beacon_node/lighthouse_network/src/rpc/codec.rs index 3550a7e0a1..5035135d43 100644 --- a/beacon_node/lighthouse_network/src/rpc/codec.rs +++ b/beacon_node/lighthouse_network/src/rpc/codec.rs @@ -15,6 +15,7 @@ use std::io::{Read, Write}; use std::marker::PhantomData; use std::sync::Arc; use tokio_util::codec::{Decoder, Encoder}; +use types::SignedExecutionPayloadEnvelope; use types::{ BlobSidecar, ChainSpec, DataColumnSidecar, DataColumnsByRootIdentifier, EthSpec, ForkContext, ForkName, Hash256, LightClientBootstrap, LightClientFinalityUpdate, @@ -76,6 +77,8 @@ impl<E: EthSpec> SSZSnappyInboundCodec<E> { }, RpcSuccessResponse::BlocksByRange(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlocksByRoot(res) => res.as_ssz_bytes(), + RpcSuccessResponse::PayloadEnvelopesByRange(res) => res.as_ssz_bytes(), + RpcSuccessResponse::PayloadEnvelopesByRoot(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlobsByRange(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlobsByRoot(res) => res.as_ssz_bytes(), RpcSuccessResponse::DataColumnsByRoot(res) => res.as_ssz_bytes(), @@ -356,6 +359,8 @@ impl<E: EthSpec> Encoder<RequestType<E>> for SSZSnappyOutboundCodec<E> { BlocksByRootRequest::V1(req) => req.block_roots.as_ssz_bytes(), BlocksByRootRequest::V2(req) => req.block_roots.as_ssz_bytes(), }, + RequestType::PayloadEnvelopesByRange(req) => req.as_ssz_bytes(), + RequestType::PayloadEnvelopesByRoot(req) => req.beacon_block_roots.as_ssz_bytes(), RequestType::BlobsByRange(req) => req.as_ssz_bytes(), RequestType::BlobsByRoot(req) => req.blob_ids.as_ssz_bytes(), RequestType::DataColumnsByRange(req) => req.as_ssz_bytes(), @@ -457,6 +462,9 @@ fn handle_error<T>( Ok(None) } } + // All snappy errors from the snap crate bubble up as `Other` kind errors + // that imply invalid response + ErrorKind::Other => Err(RPCError::InvalidData(err.to_string())), _ => Err(RPCError::from(err)), } } @@ -545,6 +553,19 @@ fn handle_rpc_request<E: EthSpec>( )?, }), ))), + SupportedProtocol::PayloadEnvelopesByRangeV1 => { + Ok(Some(RequestType::PayloadEnvelopesByRange( + PayloadEnvelopesByRangeRequest::from_ssz_bytes(decoded_buffer)?, + ))) + } + SupportedProtocol::PayloadEnvelopesByRootV1 => Ok(Some( + RequestType::PayloadEnvelopesByRoot(PayloadEnvelopesByRootRequest { + beacon_block_roots: RuntimeVariableList::from_ssz_bytes( + decoded_buffer, + spec.max_request_payloads(), + )?, + }), + )), SupportedProtocol::BlobsByRangeV1 => Ok(Some(RequestType::BlobsByRange( BlobsByRangeRequest::from_ssz_bytes(decoded_buffer)?, ))), @@ -647,6 +668,48 @@ fn handle_rpc_response<E: EthSpec>( SupportedProtocol::BlocksByRootV1 => Ok(Some(RpcSuccessResponse::BlocksByRoot(Arc::new( SignedBeaconBlock::Base(SignedBeaconBlockBase::from_ssz_bytes(decoded_buffer)?), )))), + SupportedProtocol::PayloadEnvelopesByRangeV1 => match fork_name { + Some(fork_name) => { + if fork_name.gloas_enabled() { + Ok(Some(RpcSuccessResponse::PayloadEnvelopesByRange(Arc::new( + SignedExecutionPayloadEnvelope::from_ssz_bytes(decoded_buffer)?, + )))) + } else { + Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + "Invalid fork name for payload envelopes by range".to_string(), + )) + } + } + None => Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + format!( + "No context bytes provided for {:?} response", + versioned_protocol + ), + )), + }, + SupportedProtocol::PayloadEnvelopesByRootV1 => match fork_name { + Some(fork_name) => { + if fork_name.gloas_enabled() { + Ok(Some(RpcSuccessResponse::PayloadEnvelopesByRoot(Arc::new( + SignedExecutionPayloadEnvelope::from_ssz_bytes(decoded_buffer)?, + )))) + } else { + Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + "Invalid fork name for payload envelopes by root".to_string(), + )) + } + } + None => Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + format!( + "No context bytes provided for {:?} response", + versioned_protocol + ), + )), + }, SupportedProtocol::BlobsByRangeV1 => match fork_name { Some(fork_name) => { if fork_name.deneb_enabled() { @@ -693,7 +756,7 @@ fn handle_rpc_response<E: EthSpec>( Some(fork_name) => { if fork_name.fulu_enabled() { Ok(Some(RpcSuccessResponse::DataColumnsByRoot(Arc::new( - DataColumnSidecar::from_ssz_bytes(decoded_buffer)?, + DataColumnSidecar::from_ssz_bytes_for_fork(decoded_buffer, fork_name)?, )))) } else { Err(RPCError::ErrorResponse( @@ -714,7 +777,7 @@ fn handle_rpc_response<E: EthSpec>( Some(fork_name) => { if fork_name.fulu_enabled() { Ok(Some(RpcSuccessResponse::DataColumnsByRange(Arc::new( - DataColumnSidecar::from_ssz_bytes(decoded_buffer)?, + DataColumnSidecar::from_ssz_bytes_for_fork(decoded_buffer, fork_name)?, )))) } else { Err(RPCError::ErrorResponse( @@ -923,8 +986,10 @@ mod tests { use types::{ BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BeaconBlockBellatrix, BeaconBlockHeader, DataColumnsByRootIdentifier, EmptyBlock, Epoch, FullPayload, KzgCommitment, KzgProof, - SignedBeaconBlockHeader, Slot, blob_sidecar::BlobIdentifier, data_column_sidecar::Cell, + SignedBeaconBlockHeader, Slot, + data::{BlobIdentifier, Cell}, }; + use types::{BlobSidecar, DataColumnSidecarFulu}; type Spec = types::MainnetEthSpec; @@ -988,7 +1053,7 @@ mod tests { fn empty_data_column_sidecar(spec: &ChainSpec) -> Arc<DataColumnSidecar<Spec>> { // The context bytes are now derived from the block epoch, so we need to have the slot set // here. - let data_column_sidecar = DataColumnSidecar { + let data_column_sidecar = DataColumnSidecar::Fulu(DataColumnSidecarFulu { index: 0, column: VariableList::new(vec![Cell::<Spec>::default()]).unwrap(), kzg_commitments: VariableList::new(vec![KzgCommitment::empty_for_testing()]).unwrap(), @@ -1004,7 +1069,7 @@ mod tests { signature: Signature::empty(), }, kzg_commitments_inclusion_proof: Default::default(), - }; + }); Arc::new(data_column_sidecar) } @@ -1035,9 +1100,11 @@ mod tests { let mut block: BeaconBlockBellatrix<_, FullPayload<Spec>> = BeaconBlockBellatrix::empty(spec); + // 11,000 × 1KB ≈ 11MB, just above the 10MB max_payload_size. + // Previously used 100,000 txs (~100MB) which made this test take >60s. let tx = VariableList::try_from(vec![0; 1024]).unwrap(); let txs = - VariableList::try_from(std::iter::repeat_n(tx, 100000).collect::<Vec<_>>()).unwrap(); + VariableList::try_from(std::iter::repeat_n(tx, 11000).collect::<Vec<_>>()).unwrap(); block.body.execution_payload.execution_payload.transactions = txs; @@ -1267,6 +1334,12 @@ mod tests { RequestType::BlobsByRange(blbrange) => { assert_eq!(decoded, RequestType::BlobsByRange(blbrange)) } + RequestType::PayloadEnvelopesByRange(perange) => { + assert_eq!(decoded, RequestType::PayloadEnvelopesByRange(perange)) + } + RequestType::PayloadEnvelopesByRoot(peroot) => { + assert_eq!(decoded, RequestType::PayloadEnvelopesByRoot(peroot)) + } RequestType::BlobsByRoot(bbroot) => { assert_eq!(decoded, RequestType::BlobsByRoot(bbroot)) } @@ -2355,4 +2428,43 @@ mod tests { RPCError::InvalidData(_) )); } + + /// Test invalid snappy response. + #[test] + fn test_invalid_snappy_response() { + let spec = spec_with_all_forks_enabled(); + let fork_ctx = Arc::new(fork_context(ForkName::latest(), &spec)); + let max_packet_size = spec.max_payload_size as usize; // 10 MiB. + + let protocol = ProtocolId::new(SupportedProtocol::BlocksByRangeV2, Encoding::SSZSnappy); + + let mut codec = SSZSnappyOutboundCodec::<Spec>::new( + protocol.clone(), + max_packet_size, + fork_ctx.clone(), + ); + + let mut payload = BytesMut::new(); + payload.extend_from_slice(&[0u8]); + let deneb_epoch = spec.deneb_fork_epoch.unwrap(); + payload.extend_from_slice(&fork_ctx.context_bytes(deneb_epoch)); + + // Claim the MAXIMUM allowed size (10 MiB) + let claimed_size = max_packet_size; + let mut uvi_codec: Uvi<usize> = Uvi::default(); + uvi_codec.encode(claimed_size, &mut payload).unwrap(); + payload.extend_from_slice(&[0xBB; 16]); // Junk snappy. + + let result = codec.decode(&mut payload); + + assert!(result.is_err(), "Expected decode to fail"); + + // IoError = reached snappy decode (allocation happened). + let err = result.unwrap_err(); + assert!( + matches!(err, RPCError::InvalidData(_)), + "Should return invalid data variant {}", + err + ); + } } diff --git a/beacon_node/lighthouse_network/src/rpc/config.rs b/beacon_node/lighthouse_network/src/rpc/config.rs index b0ee6fea64..9e1c6541ec 100644 --- a/beacon_node/lighthouse_network/src/rpc/config.rs +++ b/beacon_node/lighthouse_network/src/rpc/config.rs @@ -89,6 +89,8 @@ pub struct RateLimiterConfig { pub(super) goodbye_quota: Quota, pub(super) blocks_by_range_quota: Quota, pub(super) blocks_by_root_quota: Quota, + pub(super) payload_envelopes_by_range_quota: Quota, + pub(super) payload_envelopes_by_root_quota: Quota, pub(super) blobs_by_range_quota: Quota, pub(super) blobs_by_root_quota: Quota, pub(super) data_columns_by_root_quota: Quota, @@ -111,6 +113,10 @@ impl RateLimiterConfig { Quota::n_every(NonZeroU64::new(128).unwrap(), 10); pub const DEFAULT_BLOCKS_BY_ROOT_QUOTA: Quota = Quota::n_every(NonZeroU64::new(128).unwrap(), 10); + pub const DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA: Quota = + Quota::n_every(NonZeroU64::new(128).unwrap(), 10); + pub const DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA: Quota = + Quota::n_every(NonZeroU64::new(128).unwrap(), 10); // `DEFAULT_BLOCKS_BY_RANGE_QUOTA` * (target + 1) to account for high usage pub const DEFAULT_BLOBS_BY_RANGE_QUOTA: Quota = Quota::n_every(NonZeroU64::new(896).unwrap(), 10); @@ -137,6 +143,8 @@ impl Default for RateLimiterConfig { goodbye_quota: Self::DEFAULT_GOODBYE_QUOTA, blocks_by_range_quota: Self::DEFAULT_BLOCKS_BY_RANGE_QUOTA, blocks_by_root_quota: Self::DEFAULT_BLOCKS_BY_ROOT_QUOTA, + payload_envelopes_by_range_quota: Self::DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA, + payload_envelopes_by_root_quota: Self::DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA, blobs_by_range_quota: Self::DEFAULT_BLOBS_BY_RANGE_QUOTA, blobs_by_root_quota: Self::DEFAULT_BLOBS_BY_ROOT_QUOTA, data_columns_by_root_quota: Self::DEFAULT_DATA_COLUMNS_BY_ROOT_QUOTA, @@ -169,6 +177,14 @@ impl Debug for RateLimiterConfig { .field("goodbye", fmt_q!(&self.goodbye_quota)) .field("blocks_by_range", fmt_q!(&self.blocks_by_range_quota)) .field("blocks_by_root", fmt_q!(&self.blocks_by_root_quota)) + .field( + "payload_envelopes_by_range", + fmt_q!(&self.payload_envelopes_by_range_quota), + ) + .field( + "payload_envelopes_by_root", + fmt_q!(&self.payload_envelopes_by_root_quota), + ) .field("blobs_by_range", fmt_q!(&self.blobs_by_range_quota)) .field("blobs_by_root", fmt_q!(&self.blobs_by_root_quota)) .field( @@ -197,6 +213,8 @@ impl FromStr for RateLimiterConfig { let mut goodbye_quota = None; let mut blocks_by_range_quota = None; let mut blocks_by_root_quota = None; + let mut payload_envelopes_by_range_quota = None; + let mut payload_envelopes_by_root_quota = None; let mut blobs_by_range_quota = None; let mut blobs_by_root_quota = None; let mut data_columns_by_root_quota = None; @@ -214,6 +232,12 @@ impl FromStr for RateLimiterConfig { Protocol::Goodbye => goodbye_quota = goodbye_quota.or(quota), Protocol::BlocksByRange => blocks_by_range_quota = blocks_by_range_quota.or(quota), Protocol::BlocksByRoot => blocks_by_root_quota = blocks_by_root_quota.or(quota), + Protocol::PayloadEnvelopesByRange => { + payload_envelopes_by_range_quota = payload_envelopes_by_range_quota.or(quota) + } + Protocol::PayloadEnvelopesByRoot => { + payload_envelopes_by_root_quota = payload_envelopes_by_root_quota.or(quota) + } Protocol::BlobsByRange => blobs_by_range_quota = blobs_by_range_quota.or(quota), Protocol::BlobsByRoot => blobs_by_root_quota = blobs_by_root_quota.or(quota), Protocol::DataColumnsByRoot => { @@ -250,6 +274,10 @@ impl FromStr for RateLimiterConfig { .unwrap_or(Self::DEFAULT_BLOCKS_BY_RANGE_QUOTA), blocks_by_root_quota: blocks_by_root_quota .unwrap_or(Self::DEFAULT_BLOCKS_BY_ROOT_QUOTA), + payload_envelopes_by_range_quota: payload_envelopes_by_range_quota + .unwrap_or(Self::DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA), + payload_envelopes_by_root_quota: payload_envelopes_by_root_quota + .unwrap_or(Self::DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA), blobs_by_range_quota: blobs_by_range_quota .unwrap_or(Self::DEFAULT_BLOBS_BY_RANGE_QUOTA), blobs_by_root_quota: blobs_by_root_quota.unwrap_or(Self::DEFAULT_BLOBS_BY_ROOT_QUOTA), diff --git a/beacon_node/lighthouse_network/src/rpc/handler.rs b/beacon_node/lighthouse_network/src/rpc/handler.rs index 720895bbe7..336747fb83 100644 --- a/beacon_node/lighthouse_network/src/rpc/handler.rs +++ b/beacon_node/lighthouse_network/src/rpc/handler.rs @@ -13,7 +13,8 @@ use futures::prelude::*; use libp2p::PeerId; use libp2p::swarm::handler::{ ConnectionEvent, ConnectionHandler, ConnectionHandlerEvent, DialUpgradeError, - FullyNegotiatedInbound, FullyNegotiatedOutbound, StreamUpgradeError, SubstreamProtocol, + FullyNegotiatedInbound, FullyNegotiatedOutbound, ListenUpgradeError, StreamUpgradeError, + SubstreamProtocol, }; use libp2p::swarm::{ConnectionId, Stream}; use logging::crit; @@ -888,6 +889,16 @@ where ConnectionEvent::DialUpgradeError(DialUpgradeError { info, error }) => { self.on_dial_upgrade_error(info, error) } + ConnectionEvent::ListenUpgradeError(ListenUpgradeError { + error: (proto, error), + .. + }) => { + self.events_out.push(HandlerEvent::Err(HandlerErr::Inbound { + id: self.current_inbound_substream_id, + proto, + error, + })); + } _ => { // NOTE: ConnectionEvent is a non exhaustive enum so updates should be based on // release notes more than compiler feedback @@ -924,7 +935,7 @@ where request.count() )), })); - return self.shutdown(None); + return; } } RequestType::BlobsByRange(request) => { @@ -940,7 +951,36 @@ where max_allowed, max_requested_blobs )), })); - return self.shutdown(None); + return; + } + } + RequestType::PayloadEnvelopesByRange(request) => { + let max_allowed = spec.max_request_payloads; + if request.count > max_allowed { + self.events_out.push(HandlerEvent::Err(HandlerErr::Inbound { + id: self.current_inbound_substream_id, + proto: Protocol::PayloadEnvelopesByRange, + error: RPCError::InvalidData(format!( + "requested exceeded limit. allowed: {}, requested: {}", + max_allowed, request.count + )), + })); + return; + } + } + RequestType::DataColumnsByRange(request) => { + let max_requested = request.max_requested::<E>(); + let max_allowed = spec.max_request_data_column_sidecars; + if max_requested > max_allowed { + self.events_out.push(HandlerEvent::Err(HandlerErr::Inbound { + id: self.current_inbound_substream_id, + proto: Protocol::DataColumnsByRange, + error: RPCError::InvalidData(format!( + "requested exceeded limit. allowed: {}, requested: {}", + max_allowed, max_requested + )), + })); + return; } } _ => {} diff --git a/beacon_node/lighthouse_network/src/rpc/methods.rs b/beacon_node/lighthouse_network/src/rpc/methods.rs index a9b4aa2fba..baabf48683 100644 --- a/beacon_node/lighthouse_network/src/rpc/methods.rs +++ b/beacon_node/lighthouse_network/src/rpc/methods.rs @@ -12,13 +12,13 @@ use std::ops::Deref; use std::sync::Arc; use strum::IntoStaticStr; use superstruct::superstruct; -use types::blob_sidecar::BlobIdentifier; -use types::light_client_update::MAX_REQUEST_LIGHT_CLIENT_UPDATES; +use types::data::BlobIdentifier; +use types::light_client::consts::MAX_REQUEST_LIGHT_CLIENT_UPDATES; use types::{ - ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnsByRootIdentifier, Epoch, EthSpec, - ForkContext, Hash256, LightClientBootstrap, LightClientFinalityUpdate, - LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, Slot, - blob_sidecar::BlobSidecar, + BlobSidecar, ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnsByRootIdentifier, Epoch, + EthSpec, ForkContext, Hash256, LightClientBootstrap, LightClientFinalityUpdate, + LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, }; /// Maximum length of error message. @@ -363,6 +363,16 @@ impl BlocksByRangeRequest { } } +/// Request a number of execution payload envelopes from a peer. +#[derive(Encode, Decode, Clone, Debug, PartialEq)] +pub struct PayloadEnvelopesByRangeRequest { + /// The starting slot to request execution payload envelopes. + pub start_slot: u64, + + /// The number of slots from the start slot. + pub count: u64, +} + /// Request a number of beacon blobs from a peer. #[derive(Encode, Decode, Clone, Debug, PartialEq)] pub struct BlobsByRangeRequest { @@ -506,6 +516,29 @@ impl BlocksByRootRequest { } } +/// Request a number of execution payload envelopes from a peer. +#[derive(Clone, Debug, PartialEq)] +pub struct PayloadEnvelopesByRootRequest { + /// The list of beacon block roots used to request execution payload envelopes. + pub beacon_block_roots: RuntimeVariableList<Hash256>, +} + +impl PayloadEnvelopesByRootRequest { + pub fn new( + beacon_block_roots: Vec<Hash256>, + fork_context: &ForkContext, + ) -> Result<Self, String> { + let max_requests_envelopes = fork_context.spec.max_request_payloads(); + + let beacon_block_roots = + RuntimeVariableList::new(beacon_block_roots, max_requests_envelopes).map_err(|e| { + format!("ExecutionPayloadEnvelopesByRootRequest too many beacon block roots: {e:?}") + })?; + + Ok(Self { beacon_block_roots }) + } +} + /// Request a number of beacon blocks and blobs from a peer. #[derive(Clone, Debug, PartialEq)] pub struct BlobsByRootRequest { @@ -589,6 +622,13 @@ pub enum RpcSuccessResponse<E: EthSpec> { /// A response to a get BLOCKS_BY_ROOT request. BlocksByRoot(Arc<SignedBeaconBlock<E>>), + /// A response to a get EXECUTION_PAYLOAD_ENVELOPES_BY_RANGE request. A None response signifies + /// the end of the batch. + PayloadEnvelopesByRange(Arc<SignedExecutionPayloadEnvelope<E>>), + + /// A response to a get EXECUTION_PAYLOAD_ENVELOPES_BY_ROOT request. + PayloadEnvelopesByRoot(Arc<SignedExecutionPayloadEnvelope<E>>), + /// A response to a get BLOBS_BY_RANGE request BlobsByRange(Arc<BlobSidecar<E>>), @@ -629,6 +669,12 @@ pub enum ResponseTermination { /// Blocks by root stream termination. BlocksByRoot, + /// Execution payload envelopes by range stream termination. + PayloadEnvelopesByRange, + + /// Execution payload envelopes by root stream termination. + PayloadEnvelopesByRoot, + /// Blobs by range stream termination. BlobsByRange, @@ -650,6 +696,8 @@ impl ResponseTermination { match self { ResponseTermination::BlocksByRange => Protocol::BlocksByRange, ResponseTermination::BlocksByRoot => Protocol::BlocksByRoot, + ResponseTermination::PayloadEnvelopesByRange => Protocol::PayloadEnvelopesByRange, + ResponseTermination::PayloadEnvelopesByRoot => Protocol::PayloadEnvelopesByRoot, ResponseTermination::BlobsByRange => Protocol::BlobsByRange, ResponseTermination::BlobsByRoot => Protocol::BlobsByRoot, ResponseTermination::DataColumnsByRoot => Protocol::DataColumnsByRoot, @@ -745,6 +793,8 @@ impl<E: EthSpec> RpcSuccessResponse<E> { RpcSuccessResponse::Status(_) => Protocol::Status, RpcSuccessResponse::BlocksByRange(_) => Protocol::BlocksByRange, RpcSuccessResponse::BlocksByRoot(_) => Protocol::BlocksByRoot, + RpcSuccessResponse::PayloadEnvelopesByRange(_) => Protocol::PayloadEnvelopesByRange, + RpcSuccessResponse::PayloadEnvelopesByRoot(_) => Protocol::PayloadEnvelopesByRoot, RpcSuccessResponse::BlobsByRange(_) => Protocol::BlobsByRange, RpcSuccessResponse::BlobsByRoot(_) => Protocol::BlobsByRoot, RpcSuccessResponse::DataColumnsByRoot(_) => Protocol::DataColumnsByRoot, @@ -763,12 +813,9 @@ impl<E: EthSpec> RpcSuccessResponse<E> { pub fn slot(&self) -> Option<Slot> { match self { Self::BlocksByRange(r) | Self::BlocksByRoot(r) => Some(r.slot()), - Self::BlobsByRange(r) | Self::BlobsByRoot(r) => { - Some(r.signed_block_header.message.slot) - } - Self::DataColumnsByRange(r) | Self::DataColumnsByRoot(r) => { - Some(r.signed_block_header.message.slot) - } + Self::PayloadEnvelopesByRoot(r) | Self::PayloadEnvelopesByRange(r) => Some(r.slot()), + Self::BlobsByRange(r) | Self::BlobsByRoot(r) => Some(r.slot()), + Self::DataColumnsByRange(r) | Self::DataColumnsByRoot(r) => Some(r.slot()), Self::LightClientBootstrap(r) => Some(r.get_slot()), Self::LightClientFinalityUpdate(r) => Some(r.get_attested_header_slot()), Self::LightClientOptimisticUpdate(r) => Some(r.get_slot()), @@ -817,6 +864,20 @@ impl<E: EthSpec> std::fmt::Display for RpcSuccessResponse<E> { RpcSuccessResponse::BlocksByRoot(block) => { write!(f, "BlocksByRoot: Block slot: {}", block.slot()) } + RpcSuccessResponse::PayloadEnvelopesByRange(envelope) => { + write!( + f, + "ExecutionPayloadEnvelopesByRange: Envelope slot: {}", + envelope.slot() + ) + } + RpcSuccessResponse::PayloadEnvelopesByRoot(envelope) => { + write!( + f, + "ExecutionPayloadEnvelopesByRoot: Envelope slot: {}", + envelope.slot() + ) + } RpcSuccessResponse::BlobsByRange(blob) => { write!(f, "BlobsByRange: Blob slot: {}", blob.slot()) } diff --git a/beacon_node/lighthouse_network/src/rpc/protocol.rs b/beacon_node/lighthouse_network/src/rpc/protocol.rs index c0b4da097b..3e906fbdbc 100644 --- a/beacon_node/lighthouse_network/src/rpc/protocol.rs +++ b/beacon_node/lighthouse_network/src/rpc/protocol.rs @@ -11,17 +11,18 @@ use std::io; use std::marker::PhantomData; use std::sync::{Arc, LazyLock}; use std::time::Duration; -use strum::{AsRefStr, Display, EnumString, IntoStaticStr}; +use strum::{AsRefStr, Display, EnumIter, EnumString, IntoStaticStr}; use tokio_util::{ codec::Framed, compat::{Compat, FuturesAsyncReadCompatExt}, }; use types::{ - BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BlobSidecar, ChainSpec, DataColumnSidecar, - EmptyBlock, Epoch, EthSpec, EthSpecId, ForkContext, ForkName, LightClientBootstrap, - LightClientBootstrapAltair, LightClientFinalityUpdate, LightClientFinalityUpdateAltair, - LightClientOptimisticUpdate, LightClientOptimisticUpdateAltair, LightClientUpdate, - MainnetEthSpec, MinimalEthSpec, SignedBeaconBlock, + BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BlobSidecar, ChainSpec, DataColumnSidecarFulu, + DataColumnSidecarGloas, EmptyBlock, Epoch, EthSpec, EthSpecId, ForkContext, ForkName, + LightClientBootstrap, LightClientBootstrapAltair, LightClientFinalityUpdate, + LightClientFinalityUpdateAltair, LightClientOptimisticUpdate, + LightClientOptimisticUpdateAltair, LightClientUpdate, MainnetEthSpec, MinimalEthSpec, + SignedBeaconBlock, SignedExecutionPayloadEnvelope, }; // Note: Hardcoding the `EthSpec` type for `SignedBeaconBlock` as min/max values is @@ -64,6 +65,12 @@ pub static SIGNED_BEACON_BLOCK_BELLATRIX_MAX: LazyLock<usize> = + types::ExecutionPayload::<MainnetEthSpec>::max_execution_payload_bellatrix_size() // adding max size of execution payload (~16gb) + ssz::BYTES_PER_LENGTH_OFFSET); // Adding the additional ssz offset for the `ExecutionPayload` field +pub static SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MIN: LazyLock<usize> = + LazyLock::new(SignedExecutionPayloadEnvelope::<MainnetEthSpec>::min_size); + +pub static SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MAX: LazyLock<usize> = + LazyLock::new(SignedExecutionPayloadEnvelope::<MainnetEthSpec>::max_size); + pub static BLOB_SIDECAR_SIZE: LazyLock<usize> = LazyLock::new(BlobSidecar::<MainnetEthSpec>::max_size); @@ -139,13 +146,31 @@ pub fn rpc_block_limits_by_fork(current_fork: ForkName) -> RpcLimits { ), // After the merge the max SSZ size of a block is absurdly big. The size is actually // bound by other constants, so here we default to the bellatrix's max value - _ => RpcLimits::new( - *SIGNED_BEACON_BLOCK_BASE_MIN, // Base block is smaller than altair and bellatrix blocks - *SIGNED_BEACON_BLOCK_BELLATRIX_MAX, // Bellatrix block is larger than base and altair blocks + // After the merge the max SSZ size includes the execution payload. + // Gloas blocks no longer contain the execution payload, but we must + // still accept pre-Gloas blocks during historical sync, so we keep the + // Bellatrix max as the upper bound. + ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Electra + | ForkName::Fulu + | ForkName::Eip7805 + | ForkName::Gloas => RpcLimits::new( + *SIGNED_BEACON_BLOCK_BASE_MIN, + *SIGNED_BEACON_BLOCK_BELLATRIX_MAX, ), } } +/// Returns the rpc limits for payload_envelope_by_range and payload_envelope_by_root responses. +pub fn rpc_payload_limits() -> RpcLimits { + RpcLimits::new( + *SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MIN, + *SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MAX, + ) +} + fn rpc_light_client_updates_by_range_limits_by_fork(current_fork: ForkName) -> RpcLimits { let altair_fixed_len = LightClientFinalityUpdateAltair::<MainnetEthSpec>::ssz_fixed_len(); @@ -241,6 +266,12 @@ pub enum Protocol { /// The `BlobsByRange` protocol name. #[strum(serialize = "blob_sidecars_by_range")] BlobsByRange, + /// The `ExecutionPayloadEnvelopesByRoot` protocol name. + #[strum(serialize = "execution_payload_envelopes_by_root")] + PayloadEnvelopesByRoot, + /// The `ExecutionPayloadEnvelopesByRange` protocol name. + #[strum(serialize = "execution_payload_envelopes_by_range")] + PayloadEnvelopesByRange, /// The `BlobsByRoot` protocol name. #[strum(serialize = "blob_sidecars_by_root")] BlobsByRoot, @@ -276,6 +307,8 @@ impl Protocol { Protocol::Goodbye => None, Protocol::BlocksByRange => Some(ResponseTermination::BlocksByRange), Protocol::BlocksByRoot => Some(ResponseTermination::BlocksByRoot), + Protocol::PayloadEnvelopesByRange => Some(ResponseTermination::PayloadEnvelopesByRange), + Protocol::PayloadEnvelopesByRoot => Some(ResponseTermination::PayloadEnvelopesByRoot), Protocol::BlobsByRange => Some(ResponseTermination::BlobsByRange), Protocol::BlobsByRoot => Some(ResponseTermination::BlobsByRoot), Protocol::DataColumnsByRoot => Some(ResponseTermination::DataColumnsByRoot), @@ -297,7 +330,7 @@ pub enum Encoding { } /// All valid protocol name and version combinations. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] pub enum SupportedProtocol { StatusV1, StatusV2, @@ -306,6 +339,8 @@ pub enum SupportedProtocol { BlocksByRangeV2, BlocksByRootV1, BlocksByRootV2, + PayloadEnvelopesByRangeV1, + PayloadEnvelopesByRootV1, BlobsByRangeV1, BlobsByRootV1, DataColumnsByRootV1, @@ -328,6 +363,8 @@ impl SupportedProtocol { SupportedProtocol::GoodbyeV1 => "1", SupportedProtocol::BlocksByRangeV1 => "1", SupportedProtocol::BlocksByRangeV2 => "2", + SupportedProtocol::PayloadEnvelopesByRangeV1 => "1", + SupportedProtocol::PayloadEnvelopesByRootV1 => "1", SupportedProtocol::BlocksByRootV1 => "1", SupportedProtocol::BlocksByRootV2 => "2", SupportedProtocol::BlobsByRangeV1 => "1", @@ -354,6 +391,8 @@ impl SupportedProtocol { SupportedProtocol::BlocksByRangeV2 => Protocol::BlocksByRange, SupportedProtocol::BlocksByRootV1 => Protocol::BlocksByRoot, SupportedProtocol::BlocksByRootV2 => Protocol::BlocksByRoot, + SupportedProtocol::PayloadEnvelopesByRangeV1 => Protocol::PayloadEnvelopesByRange, + SupportedProtocol::PayloadEnvelopesByRootV1 => Protocol::PayloadEnvelopesByRoot, SupportedProtocol::BlobsByRangeV1 => Protocol::BlobsByRange, SupportedProtocol::BlobsByRootV1 => Protocol::BlobsByRoot, SupportedProtocol::DataColumnsByRootV1 => Protocol::DataColumnsByRoot, @@ -408,6 +447,18 @@ impl SupportedProtocol { ProtocolId::new(SupportedProtocol::DataColumnsByRangeV1, Encoding::SSZSnappy), ]); } + if fork_context.fork_exists(ForkName::Gloas) { + supported.extend_from_slice(&[ + ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRangeV1, + Encoding::SSZSnappy, + ), + ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRootV1, + Encoding::SSZSnappy, + ), + ]); + } supported } } @@ -449,6 +500,10 @@ impl<E: EthSpec> UpgradeInfo for RPCProtocol<E> { SupportedProtocol::LightClientFinalityUpdateV1, Encoding::SSZSnappy, )); + supported_protocols.push(ProtocolId::new( + SupportedProtocol::LightClientUpdatesByRangeV1, + Encoding::SSZSnappy, + )); } supported_protocols } @@ -510,6 +565,13 @@ impl ProtocolId { <OldBlocksByRangeRequestV2 as Encode>::ssz_fixed_len(), ), Protocol::BlocksByRoot => RpcLimits::new(0, spec.max_blocks_by_root_request), + Protocol::PayloadEnvelopesByRange => RpcLimits::new( + <PayloadEnvelopesByRangeRequest as Encode>::ssz_fixed_len(), + <PayloadEnvelopesByRangeRequest as Encode>::ssz_fixed_len(), + ), + Protocol::PayloadEnvelopesByRoot => { + RpcLimits::new(0, spec.max_payload_envelopes_by_root_request) + } Protocol::BlobsByRange => RpcLimits::new( <BlobsByRangeRequest as Encode>::ssz_fixed_len(), <BlobsByRangeRequest as Encode>::ssz_fixed_len(), @@ -548,6 +610,8 @@ impl ProtocolId { Protocol::Goodbye => RpcLimits::new(0, 0), // Goodbye request has no response Protocol::BlocksByRange => rpc_block_limits_by_fork(fork_context.current_fork_name()), Protocol::BlocksByRoot => rpc_block_limits_by_fork(fork_context.current_fork_name()), + Protocol::PayloadEnvelopesByRange => rpc_payload_limits(), + Protocol::PayloadEnvelopesByRoot => rpc_payload_limits(), Protocol::BlobsByRange => rpc_blob_limits::<E>(), Protocol::BlobsByRoot => rpc_blob_limits::<E>(), Protocol::DataColumnsByRoot => { @@ -585,6 +649,8 @@ impl ProtocolId { match self.versioned_protocol { SupportedProtocol::BlocksByRangeV2 | SupportedProtocol::BlocksByRootV2 + | SupportedProtocol::PayloadEnvelopesByRangeV1 + | SupportedProtocol::PayloadEnvelopesByRootV1 | SupportedProtocol::BlobsByRangeV1 | SupportedProtocol::BlobsByRootV1 | SupportedProtocol::DataColumnsByRootV1 @@ -640,10 +706,23 @@ pub fn rpc_data_column_limits<E: EthSpec>( current_digest_epoch: Epoch, spec: &ChainSpec, ) -> RpcLimits { - RpcLimits::new( - DataColumnSidecar::<E>::min_size(), - DataColumnSidecar::<E>::max_size(spec.max_blobs_per_block(current_digest_epoch) as usize), - ) + let fork_name = spec.fork_name_at_epoch(current_digest_epoch); + + if fork_name.gloas_enabled() { + RpcLimits::new( + DataColumnSidecarGloas::<E>::min_size(), + DataColumnSidecarGloas::<E>::max_size( + spec.max_blobs_per_block(current_digest_epoch) as usize + ), + ) + } else { + RpcLimits::new( + DataColumnSidecarFulu::<E>::min_size(), + DataColumnSidecarFulu::<E>::max_size( + spec.max_blobs_per_block(current_digest_epoch) as usize + ), + ) + } } /* Inbound upgrade */ @@ -661,7 +740,7 @@ where E: EthSpec, { type Output = InboundOutput<TSocket, E>; - type Error = RPCError; + type Error = (Protocol, RPCError); type Future = BoxFuture<'static, Result<Self::Output, Self::Error>>; fn upgrade_inbound(self, socket: TSocket, protocol: ProtocolId) -> Self::Future { @@ -703,10 +782,12 @@ where ) .await { - Err(e) => Err(RPCError::from(e)), + Err(e) => Err((versioned_protocol.protocol(), RPCError::from(e))), Ok((Some(Ok(request)), stream)) => Ok((request, stream)), - Ok((Some(Err(e)), _)) => Err(e), - Ok((None, _)) => Err(RPCError::IncompleteStream), + Ok((Some(Err(e)), _)) => Err((versioned_protocol.protocol(), e)), + Ok((None, _)) => { + Err((versioned_protocol.protocol(), RPCError::IncompleteStream)) + } } } } @@ -715,12 +796,14 @@ where } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, IntoStaticStr)] pub enum RequestType<E: EthSpec> { Status(StatusMessage), Goodbye(GoodbyeReason), BlocksByRange(OldBlocksByRangeRequest), BlocksByRoot(BlocksByRootRequest), + PayloadEnvelopesByRange(PayloadEnvelopesByRangeRequest), + PayloadEnvelopesByRoot(PayloadEnvelopesByRootRequest), BlobsByRange(BlobsByRangeRequest), BlobsByRoot(BlobsByRootRequest), DataColumnsByRoot(DataColumnsByRootRequest<E>), @@ -744,6 +827,8 @@ impl<E: EthSpec> RequestType<E> { RequestType::Goodbye(_) => 0, RequestType::BlocksByRange(req) => *req.count(), RequestType::BlocksByRoot(req) => req.block_roots().len() as u64, + RequestType::PayloadEnvelopesByRange(req) => req.count, + RequestType::PayloadEnvelopesByRoot(req) => req.beacon_block_roots.len() as u64, RequestType::BlobsByRange(req) => req.max_blobs_requested(digest_epoch, spec), RequestType::BlobsByRoot(req) => req.blob_ids.len() as u64, RequestType::DataColumnsByRoot(req) => req.max_requested() as u64, @@ -773,6 +858,8 @@ impl<E: EthSpec> RequestType<E> { BlocksByRootRequest::V1(_) => SupportedProtocol::BlocksByRootV1, BlocksByRootRequest::V2(_) => SupportedProtocol::BlocksByRootV2, }, + RequestType::PayloadEnvelopesByRange(_) => SupportedProtocol::PayloadEnvelopesByRangeV1, + RequestType::PayloadEnvelopesByRoot(_) => SupportedProtocol::PayloadEnvelopesByRootV1, RequestType::BlobsByRange(_) => SupportedProtocol::BlobsByRangeV1, RequestType::BlobsByRoot(_) => SupportedProtocol::BlobsByRootV1, RequestType::DataColumnsByRoot(_) => SupportedProtocol::DataColumnsByRootV1, @@ -804,6 +891,8 @@ impl<E: EthSpec> RequestType<E> { // variants that have `multiple_responses()` can have values. RequestType::BlocksByRange(_) => ResponseTermination::BlocksByRange, RequestType::BlocksByRoot(_) => ResponseTermination::BlocksByRoot, + RequestType::PayloadEnvelopesByRange(_) => ResponseTermination::PayloadEnvelopesByRange, + RequestType::PayloadEnvelopesByRoot(_) => ResponseTermination::PayloadEnvelopesByRoot, RequestType::BlobsByRange(_) => ResponseTermination::BlobsByRange, RequestType::BlobsByRoot(_) => ResponseTermination::BlobsByRoot, RequestType::DataColumnsByRoot(_) => ResponseTermination::DataColumnsByRoot, @@ -838,6 +927,14 @@ impl<E: EthSpec> RequestType<E> { ProtocolId::new(SupportedProtocol::BlocksByRootV2, Encoding::SSZSnappy), ProtocolId::new(SupportedProtocol::BlocksByRootV1, Encoding::SSZSnappy), ], + RequestType::PayloadEnvelopesByRange(_) => vec![ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRangeV1, + Encoding::SSZSnappy, + )], + RequestType::PayloadEnvelopesByRoot(_) => vec![ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRootV1, + Encoding::SSZSnappy, + )], RequestType::BlobsByRange(_) => vec![ProtocolId::new( SupportedProtocol::BlobsByRangeV1, Encoding::SSZSnappy, @@ -889,6 +986,8 @@ impl<E: EthSpec> RequestType<E> { RequestType::BlocksByRange(_) => false, RequestType::BlocksByRoot(_) => false, RequestType::BlobsByRange(_) => false, + RequestType::PayloadEnvelopesByRange(_) => false, + RequestType::PayloadEnvelopesByRoot(_) => false, RequestType::BlobsByRoot(_) => false, RequestType::DataColumnsByRoot(_) => false, RequestType::DataColumnsByRange(_) => false, @@ -999,6 +1098,12 @@ impl<E: EthSpec> std::fmt::Display for RequestType<E> { RequestType::Goodbye(reason) => write!(f, "Goodbye: {}", reason), RequestType::BlocksByRange(req) => write!(f, "Blocks by range: {}", req), RequestType::BlocksByRoot(req) => write!(f, "Blocks by root: {:?}", req), + RequestType::PayloadEnvelopesByRange(req) => { + write!(f, "Payload envelopes by range: {:?}", req) + } + RequestType::PayloadEnvelopesByRoot(req) => { + write!(f, "Payload envelopes by root: {:?}", req) + } RequestType::BlobsByRange(req) => write!(f, "Blobs by range: {:?}", req), RequestType::BlobsByRoot(req) => write!(f, "Blobs by root: {:?}", req), RequestType::DataColumnsByRoot(req) => write!(f, "Data columns by root: {:?}", req), @@ -1033,3 +1138,101 @@ impl RPCError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use libp2p::core::UpgradeInfo; + use std::collections::HashSet; + use strum::IntoEnumIterator; + use types::{Hash256, Slot}; + + type E = MainnetEthSpec; + + /// Whether this protocol should appear in `currently_supported()` for the given context. + /// + /// Uses an exhaustive match so that adding a new `SupportedProtocol` variant + /// causes a compile error until this function is updated. + fn expected_in_currently_supported( + protocol: SupportedProtocol, + fork_context: &ForkContext, + ) -> bool { + use SupportedProtocol::*; + match protocol { + StatusV1 | StatusV2 | GoodbyeV1 | PingV1 | BlocksByRangeV1 | BlocksByRangeV2 + | BlocksByRootV1 | BlocksByRootV2 | MetaDataV1 | MetaDataV2 => true, + + BlobsByRangeV1 | BlobsByRootV1 => fork_context.fork_exists(ForkName::Deneb), + + DataColumnsByRootV1 | DataColumnsByRangeV1 | MetaDataV3 => { + fork_context.spec.is_peer_das_scheduled() + } + + PayloadEnvelopesByRangeV1 | PayloadEnvelopesByRootV1 => { + fork_context.fork_exists(ForkName::Gloas) + } + + // Light client protocols are not in currently_supported() + LightClientBootstrapV1 + | LightClientOptimisticUpdateV1 + | LightClientFinalityUpdateV1 + | LightClientUpdatesByRangeV1 => false, + } + } + + /// Whether this protocol should appear in `protocol_info()` when light client server is + /// enabled. + /// + /// Uses an exhaustive match so that adding a new `SupportedProtocol` variant + /// causes a compile error until this function is updated. + fn expected_in_protocol_info(protocol: SupportedProtocol, fork_context: &ForkContext) -> bool { + use SupportedProtocol::*; + match protocol { + LightClientBootstrapV1 + | LightClientOptimisticUpdateV1 + | LightClientFinalityUpdateV1 + | LightClientUpdatesByRangeV1 => true, + + _ => expected_in_currently_supported(protocol, fork_context), + } + } + + #[test] + fn all_protocols_registered() { + for fork in ForkName::list_all() { + let spec = fork.make_genesis_spec(E::default_spec()); + let fork_context = Arc::new(ForkContext::new::<E>(Slot::new(0), Hash256::ZERO, &spec)); + + let currently_supported: HashSet<SupportedProtocol> = + SupportedProtocol::currently_supported(&fork_context) + .into_iter() + .map(|pid| pid.versioned_protocol) + .collect(); + + let rpc_protocol = RPCProtocol::<E> { + fork_context: fork_context.clone(), + max_rpc_size: spec.max_payload_size as usize, + enable_light_client_server: true, + phantom: PhantomData, + }; + let protocol_info: HashSet<SupportedProtocol> = rpc_protocol + .protocol_info() + .into_iter() + .map(|pid| pid.versioned_protocol) + .collect(); + + for protocol in SupportedProtocol::iter() { + assert_eq!( + currently_supported.contains(&protocol), + expected_in_currently_supported(protocol, &fork_context), + "{protocol:?} registration mismatch in currently_supported() at {fork:?}" + ); + assert_eq!( + protocol_info.contains(&protocol), + expected_in_protocol_info(protocol, &fork_context), + "{protocol:?} registration mismatch in protocol_info() at {fork:?}" + ); + } + } + } +} diff --git a/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs b/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs index 8b364f506c..ebdca386d8 100644 --- a/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs +++ b/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs @@ -77,6 +77,14 @@ impl Quota { max_tokens: n, } } + + #[cfg(test)] + pub const fn n_every_millis(n: NonZeroU64, millis: u64) -> Self { + Quota { + replenish_all_every: Duration::from_millis(millis), + max_tokens: n, + } + } } /// Manages rate limiting of requests per peer, with differentiated rates per protocol. @@ -101,7 +109,11 @@ pub struct RPCRateLimiter { blbrange_rl: Limiter<PeerId>, /// BlobsByRoot rate limiter. blbroot_rl: Limiter<PeerId>, - /// DataColumnssByRoot rate limiter. + /// PayloadEnvelopesByRange rate limiter. + envrange_rl: Limiter<PeerId>, + /// PayloadEnvelopesByRoot rate limiter. + envroots_rl: Limiter<PeerId>, + /// DataColumnsByRoot rate limiter. dcbroot_rl: Limiter<PeerId>, /// DataColumnsByRange rate limiter. dcbrange_rl: Limiter<PeerId>, @@ -140,6 +152,10 @@ pub struct RPCRateLimiterBuilder { bbrange_quota: Option<Quota>, /// Quota for the BlocksByRoot protocol. bbroots_quota: Option<Quota>, + /// Quota for the ExecutionPayloadEnvelopesByRange protocol. + perange_quota: Option<Quota>, + /// Quota for the ExecutionPayloadEnvelopesByRoot protocol. + peroots_quota: Option<Quota>, /// Quota for the BlobsByRange protocol. blbrange_quota: Option<Quota>, /// Quota for the BlobsByRoot protocol. @@ -169,6 +185,8 @@ impl RPCRateLimiterBuilder { Protocol::Goodbye => self.goodbye_quota = q, Protocol::BlocksByRange => self.bbrange_quota = q, Protocol::BlocksByRoot => self.bbroots_quota = q, + Protocol::PayloadEnvelopesByRange => self.perange_quota = q, + Protocol::PayloadEnvelopesByRoot => self.peroots_quota = q, Protocol::BlobsByRange => self.blbrange_quota = q, Protocol::BlobsByRoot => self.blbroot_quota = q, Protocol::DataColumnsByRoot => self.dcbroot_quota = q, @@ -193,6 +211,12 @@ impl RPCRateLimiterBuilder { let bbrange_quota = self .bbrange_quota .ok_or("BlocksByRange quota not specified")?; + let perange_quota = self + .perange_quota + .ok_or("PayloadEnvelopesByRange quota not specified")?; + let peroots_quota = self + .peroots_quota + .ok_or("PayloadEnvelopesByRoot quota not specified")?; let lc_bootstrap_quota = self .lcbootstrap_quota .ok_or("LightClientBootstrap quota not specified")?; @@ -228,6 +252,8 @@ impl RPCRateLimiterBuilder { let goodbye_rl = Limiter::from_quota(goodbye_quota)?; let bbroots_rl = Limiter::from_quota(bbroots_quota)?; let bbrange_rl = Limiter::from_quota(bbrange_quota)?; + let envrange_rl = Limiter::from_quota(perange_quota)?; + let envroots_rl = Limiter::from_quota(peroots_quota)?; let blbrange_rl = Limiter::from_quota(blbrange_quota)?; let blbroot_rl = Limiter::from_quota(blbroots_quota)?; let dcbroot_rl = Limiter::from_quota(dcbroot_quota)?; @@ -251,6 +277,8 @@ impl RPCRateLimiterBuilder { goodbye_rl, bbroots_rl, bbrange_rl, + envrange_rl, + envroots_rl, blbrange_rl, blbroot_rl, dcbroot_rl, @@ -304,6 +332,8 @@ impl RPCRateLimiter { goodbye_quota, blocks_by_range_quota, blocks_by_root_quota, + payload_envelopes_by_range_quota, + payload_envelopes_by_root_quota, blobs_by_range_quota, blobs_by_root_quota, data_columns_by_root_quota, @@ -321,6 +351,14 @@ impl RPCRateLimiter { .set_quota(Protocol::Goodbye, goodbye_quota) .set_quota(Protocol::BlocksByRange, blocks_by_range_quota) .set_quota(Protocol::BlocksByRoot, blocks_by_root_quota) + .set_quota( + Protocol::PayloadEnvelopesByRange, + payload_envelopes_by_range_quota, + ) + .set_quota( + Protocol::PayloadEnvelopesByRoot, + payload_envelopes_by_root_quota, + ) .set_quota(Protocol::BlobsByRange, blobs_by_range_quota) .set_quota(Protocol::BlobsByRoot, blobs_by_root_quota) .set_quota(Protocol::DataColumnsByRoot, data_columns_by_root_quota) @@ -368,6 +406,8 @@ impl RPCRateLimiter { Protocol::Goodbye => &mut self.goodbye_rl, Protocol::BlocksByRange => &mut self.bbrange_rl, Protocol::BlocksByRoot => &mut self.bbroots_rl, + Protocol::PayloadEnvelopesByRange => &mut self.envrange_rl, + Protocol::PayloadEnvelopesByRoot => &mut self.envroots_rl, Protocol::BlobsByRange => &mut self.blbrange_rl, Protocol::BlobsByRoot => &mut self.blbroot_rl, Protocol::DataColumnsByRoot => &mut self.dcbroot_rl, @@ -392,6 +432,8 @@ impl RPCRateLimiter { status_rl, bbrange_rl, bbroots_rl, + envrange_rl, + envroots_rl, blbrange_rl, blbroot_rl, dcbroot_rl, @@ -409,6 +451,8 @@ impl RPCRateLimiter { status_rl.prune(time_since_start); bbrange_rl.prune(time_since_start); bbroots_rl.prune(time_since_start); + envrange_rl.prune(time_since_start); + envroots_rl.prune(time_since_start); blbrange_rl.prune(time_since_start); blbroot_rl.prune(time_since_start); dcbrange_rl.prune(time_since_start); diff --git a/beacon_node/lighthouse_network/src/rpc/self_limiter.rs b/beacon_node/lighthouse_network/src/rpc/self_limiter.rs index 90e2db9135..2a7ef955a1 100644 --- a/beacon_node/lighthouse_network/src/rpc/self_limiter.rs +++ b/beacon_node/lighthouse_network/src/rpc/self_limiter.rs @@ -4,6 +4,10 @@ use super::{ rate_limiter::{RPCRateLimiter as RateLimiter, RateLimitedErr}, }; use crate::rpc::rate_limiter::RateLimiterItem; +use futures::FutureExt; +use libp2p::{PeerId, swarm::NotifyHandler}; +use logging::crit; +use smallvec::SmallVec; use std::time::{SystemTime, UNIX_EPOCH}; use std::{ collections::{HashMap, VecDeque, hash_map::Entry}, @@ -11,11 +15,6 @@ use std::{ task::{Context, Poll}, time::Duration, }; - -use futures::FutureExt; -use libp2p::{PeerId, swarm::NotifyHandler}; -use logging::crit; -use smallvec::SmallVec; use tokio_util::time::DelayQueue; use tracing::debug; use types::{EthSpec, ForkContext}; @@ -234,9 +233,29 @@ impl<Id: ReqId, E: EthSpec> SelfRateLimiter<Id, E> { pub fn peer_disconnected(&mut self, peer_id: PeerId) -> Vec<(Id, Protocol)> { self.active_requests.remove(&peer_id); + let mut failed_requests = Vec::new(); + + self.ready_requests.retain(|(req_peer_id, rpc_send, _)| { + if let RPCSend::Request(request_id, req) = rpc_send { + if req_peer_id == &peer_id { + failed_requests.push((*request_id, req.protocol())); + // Remove the entry + false + } else { + // Keep the entry + true + } + } else { + debug_assert!( + false, + "Coding error: unexpected RPCSend variant {rpc_send:?}." + ); + false + } + }); + // It's not ideal to iterate this map, but the key is (PeerId, Protocol) and this map // should never really be large. So we iterate for simplicity - let mut failed_requests = Vec::new(); self.delayed_requests .retain(|(map_peer_id, protocol), queue| { if map_peer_id == &peer_id { @@ -252,6 +271,7 @@ impl<Id: ReqId, E: EthSpec> SelfRateLimiter<Id, E> { true } }); + failed_requests } @@ -549,4 +569,72 @@ mod tests { .contains_key(&(peer2, Protocol::Ping)) ); } + + /// Test that `peer_disconnected` returns the IDs of pending requests. + #[tokio::test] + async fn test_peer_disconnected_returns_failed_requests() { + const REPLENISH_DURATION: u64 = 50; + let fork_context = std::sync::Arc::new(ForkContext::new::<MainnetEthSpec>( + Slot::new(0), + Hash256::ZERO, + &MainnetEthSpec::default_spec(), + )); + let config = OutboundRateLimiterConfig(RateLimiterConfig { + ping_quota: Quota::n_every_millis(NonZeroU64::new(1).unwrap(), REPLENISH_DURATION), + ..Default::default() + }); + let mut limiter: SelfRateLimiter<AppRequestId, MainnetEthSpec> = + SelfRateLimiter::new(Some(config), fork_context).unwrap(); + let peer_id = PeerId::random(); + + for i in 1..=5u32 { + let result = limiter.allows( + peer_id, + AppRequestId::Sync(SyncRequestId::SingleBlock { + id: SingleLookupReqId { + req_id: i, + lookup_id: i, + }, + }), + RequestType::Ping(Ping { data: i as u64 }), + ); + + // Check that the limiter allows the first request while other requests are added to the queue. + if i == 1 { + assert!(result.is_ok()); + } else { + assert!(result.is_err()); + } + } + + // Wait until the tokens have been regenerated, then run `next_peer_request_ready`. + tokio::time::sleep(Duration::from_millis(REPLENISH_DURATION + 10)).await; + limiter.next_peer_request_ready(peer_id, Protocol::Ping); + + // Check that one of the pending requests has moved to ready_requests. + assert_eq!( + limiter + .delayed_requests + .get(&(peer_id, Protocol::Ping)) + .unwrap() + .len(), + 3 + ); + assert_eq!(limiter.ready_requests.len(), 1); + + let mut failed_requests = limiter.peer_disconnected(peer_id); + + // Check that the limiter returns the IDs of pending requests and that the IDs are ordered correctly. + assert_eq!(failed_requests.len(), 4); + for i in 2..=5u32 { + let (request_id, protocol) = failed_requests.remove(0); + assert!(matches!( + request_id, + AppRequestId::Sync(SyncRequestId::SingleBlock { + id: SingleLookupReqId { req_id, .. }, + }) if req_id == i + )); + assert_eq!(protocol, Protocol::Ping); + } + } } diff --git a/beacon_node/lighthouse_network/src/service/api_types.rs b/beacon_node/lighthouse_network/src/service/api_types.rs index f1a4d87de7..486a443857 100644 --- a/beacon_node/lighthouse_network/src/service/api_types.rs +++ b/beacon_node/lighthouse_network/src/service/api_types.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use types::{ BlobSidecar, DataColumnSidecar, Epoch, EthSpec, LightClientBootstrap, LightClientFinalityUpdate, LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, }; pub type Id = u32; @@ -135,7 +136,7 @@ pub struct CustodyId { pub struct CustodyRequester(pub SingleLookupReqId); /// Application level requests sent to the network. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum AppRequestId { Sync(SyncRequestId), Router, @@ -160,6 +161,10 @@ pub enum Response<E: EthSpec> { DataColumnsByRange(Option<Arc<DataColumnSidecar<E>>>), /// A response to a get BLOCKS_BY_ROOT request. BlocksByRoot(Option<Arc<SignedBeaconBlock<E>>>), + /// A response to a get `EXECUTION_PAYLOAD_ENVELOPES_BY_ROOT` request. + PayloadEnvelopesByRoot(Option<Arc<SignedExecutionPayloadEnvelope<E>>>), + /// A response to a get `EXECUTION_PAYLOAD_ENVELOPES_BY_RANGE` request. + PayloadEnvelopesByRange(Option<Arc<SignedExecutionPayloadEnvelope<E>>>), /// A response to a get BLOBS_BY_ROOT request. BlobsByRoot(Option<Arc<BlobSidecar<E>>>), /// A response to a get DATA_COLUMN_SIDECARS_BY_ROOT request. @@ -185,6 +190,16 @@ impl<E: EthSpec> std::convert::From<Response<E>> for RpcResponse<E> { Some(b) => RpcResponse::Success(RpcSuccessResponse::BlocksByRange(b)), None => RpcResponse::StreamTermination(ResponseTermination::BlocksByRange), }, + Response::PayloadEnvelopesByRoot(r) => match r { + Some(p) => RpcResponse::Success(RpcSuccessResponse::PayloadEnvelopesByRoot(p)), + None => RpcResponse::StreamTermination(ResponseTermination::PayloadEnvelopesByRoot), + }, + Response::PayloadEnvelopesByRange(r) => match r { + Some(p) => RpcResponse::Success(RpcSuccessResponse::PayloadEnvelopesByRange(p)), + None => { + RpcResponse::StreamTermination(ResponseTermination::PayloadEnvelopesByRange) + } + }, Response::BlobsByRoot(r) => match r { Some(b) => RpcResponse::Success(RpcSuccessResponse::BlobsByRoot(b)), None => RpcResponse::StreamTermination(ResponseTermination::BlobsByRoot), diff --git a/beacon_node/lighthouse_network/src/service/gossip_cache.rs b/beacon_node/lighthouse_network/src/service/gossip_cache.rs index b8286fa3c8..a3266e97e5 100644 --- a/beacon_node/lighthouse_network/src/service/gossip_cache.rs +++ b/beacon_node/lighthouse_network/src/service/gossip_cache.rs @@ -40,6 +40,14 @@ pub struct GossipCache { sync_committee_message: Option<Duration>, /// Timeout for signed BLS to execution changes. bls_to_execution_change: Option<Duration>, + /// Timeout for signed execution payload envelope. + execution_payload: Option<Duration>, + /// Timeout for execution payload bid. + execution_payload_bid: Option<Duration>, + /// Timeout for payload attestation message. + payload_attestation: Option<Duration>, + /// Timeout for proposer preferences. + proposer_preferences: Option<Duration>, /// Timeout for light client finality updates. light_client_finality_update: Option<Duration>, /// Timeout for light client optimistic updates. @@ -71,6 +79,14 @@ pub struct GossipCacheBuilder { sync_committee_message: Option<Duration>, /// Timeout for signed BLS to execution changes. bls_to_execution_change: Option<Duration>, + /// Timeout for signed execution payload envelope. + execution_payload: Option<Duration>, + /// Timeout for execution payload bid. + execution_payload_bid: Option<Duration>, + /// Timeout for payload attestation message. + payload_attestation: Option<Duration>, + /// Timeout for proposer preferences. + proposer_preferences: Option<Duration>, /// Timeout for light client finality updates. light_client_finality_update: Option<Duration>, /// Timeout for light client optimistic updates. @@ -139,6 +155,30 @@ impl GossipCacheBuilder { self } + /// Timeout for signed execution payload envelope. + pub fn execution_payload_timeout(mut self, timeout: Duration) -> Self { + self.execution_payload = Some(timeout); + self + } + + /// Timeout for execution payload bid. + pub fn execution_payload_bid_timeout(mut self, timeout: Duration) -> Self { + self.execution_payload_bid = Some(timeout); + self + } + + /// Timeout for payload attestation message. + pub fn payload_attestation_timeout(mut self, timeout: Duration) -> Self { + self.payload_attestation = Some(timeout); + self + } + + /// Timeout for proposer preferences. + pub fn proposer_preferences_timeout(mut self, timeout: Duration) -> Self { + self.proposer_preferences = Some(timeout); + self + } + /// Timeout for light client finality update messages. pub fn light_client_finality_update_timeout(mut self, timeout: Duration) -> Self { self.light_client_finality_update = Some(timeout); @@ -165,6 +205,10 @@ impl GossipCacheBuilder { signed_contribution_and_proof, sync_committee_message, bls_to_execution_change, + execution_payload, + execution_payload_bid, + payload_attestation, + proposer_preferences, light_client_finality_update, light_client_optimistic_update, } = self; @@ -182,6 +226,10 @@ impl GossipCacheBuilder { signed_contribution_and_proof: signed_contribution_and_proof.or(default_timeout), sync_committee_message: sync_committee_message.or(default_timeout), bls_to_execution_change: bls_to_execution_change.or(default_timeout), + execution_payload: execution_payload.or(default_timeout), + execution_payload_bid: execution_payload_bid.or(default_timeout), + payload_attestation: payload_attestation.or(default_timeout), + proposer_preferences: proposer_preferences.or(default_timeout), light_client_finality_update: light_client_finality_update.or(default_timeout), light_client_optimistic_update: light_client_optimistic_update.or(default_timeout), } @@ -209,6 +257,10 @@ impl GossipCache { GossipKind::SignedContributionAndProof => self.signed_contribution_and_proof, GossipKind::SyncCommitteeMessage(_) => self.sync_committee_message, GossipKind::BlsToExecutionChange => self.bls_to_execution_change, + GossipKind::ExecutionPayload => self.execution_payload, + GossipKind::ExecutionPayloadBid => self.execution_payload_bid, + GossipKind::PayloadAttestation => self.payload_attestation, + GossipKind::ProposerPreferences => self.proposer_preferences, GossipKind::LightClientFinalityUpdate => self.light_client_finality_update, GossipKind::LightClientOptimisticUpdate => self.light_client_optimistic_update, GossipKind::InclusionList => None, diff --git a/beacon_node/lighthouse_network/src/service/gossipsub_scoring_parameters.rs b/beacon_node/lighthouse_network/src/service/gossipsub_scoring_parameters.rs index 873d3f9252..7d1fa2d4dc 100644 --- a/beacon_node/lighthouse_network/src/service/gossipsub_scoring_parameters.rs +++ b/beacon_node/lighthouse_network/src/service/gossipsub_scoring_parameters.rs @@ -1,6 +1,8 @@ use crate::TopicHash; use crate::types::{GossipEncoding, GossipKind, GossipTopic}; -use gossipsub::{IdentTopic as Topic, PeerScoreParams, PeerScoreThresholds, TopicScoreParams}; +use libp2p::gossipsub::{ + IdentTopic as Topic, PeerScoreParams, PeerScoreThresholds, TopicScoreParams, +}; use std::cmp::max; use std::collections::HashMap; use std::marker::PhantomData; @@ -52,7 +54,7 @@ pub struct PeerScoreSettings<E: EthSpec> { impl<E: EthSpec> PeerScoreSettings<E> { pub fn new(chain_spec: &ChainSpec, mesh_n: usize) -> PeerScoreSettings<E> { - let slot = Duration::from_secs(chain_spec.seconds_per_slot); + let slot = chain_spec.get_slot_duration(); let beacon_attestation_subnet_weight = 1.0 / chain_spec.attestation_subnet_count as f64; let max_positive_score = (MAX_IN_MESH_SCORE + MAX_FIRST_MESSAGE_DELIVERIES_SCORE) * (BEACON_BLOCK_WEIGHT diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 4eebda1dec..f0c1567cb0 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -14,18 +14,20 @@ use crate::rpc::{ GoodbyeReason, HandlerErr, InboundRequestId, Protocol, RPC, RPCError, RPCMessage, RPCReceived, RequestType, ResponseTermination, RpcResponse, RpcSuccessResponse, }; +use crate::service::partial_column_header_tracker::PartialColumnHeaderTracker; use crate::types::{ - GossipEncoding, GossipKind, GossipTopic, SnappyTransform, Subnet, SubnetDiscovery, - all_topics_at_fork, core_topics_to_subscribe, is_fork_non_core_topic, subnet_from_topic_hash, + GossipEncoding, GossipKind, GossipTopic, OutgoingPartialColumn, SnappyTransform, Subnet, + SubnetDiscovery, all_topics_at_fork, core_topics_to_subscribe, is_fork_non_core_topic, + subnet_from_topic_hash, }; -use crate::{Enr, NetworkGlobals, PubsubMessage, TopicHash, metrics}; +use crate::{Enr, NetworkGlobals, PubsubMessage, TopicHash, decode_partial, metrics}; use api_types::{AppRequestId, Response}; use futures::stream::StreamExt; -use gossipsub::{ - IdentTopic as Topic, MessageAcceptance, MessageAuthenticity, MessageId, PublishError, - TopicScoreParams, -}; use gossipsub_scoring_parameters::{PeerScoreSettings, lighthouse_gossip_thresholds}; +use libp2p::gossipsub::{ + self, Event, IdentTopic as Topic, MessageAcceptance, MessageAuthenticity, MessageId, + PublishError, TopicScoreParams, +}; use libp2p::identity::Keypair; use libp2p::multiaddr::{self, Multiaddr, Protocol as MProtocol}; use libp2p::swarm::behaviour::toggle::Toggle; @@ -40,16 +42,18 @@ use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, trace, warn}; -use types::{ChainSpec, ForkName}; use types::{ - EnrForkId, EthSpec, ForkContext, Slot, SubnetId, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, + ChainSpec, DataColumnSubnetId, EnrForkId, EthSpec, ForkContext, ForkName, PartialDataColumn, + PartialDataColumnHeader, Slot, SubnetId, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, }; use utils::{Context as ServiceContext, build_transport, strip_peer_id}; pub mod api_types; mod gossip_cache; pub mod gossipsub_scoring_parameters; +mod partial_column_header_tracker; pub mod utils; + /// The number of peers we target per subnet for discovery queries. pub const TARGET_SUBNET_PEERS: usize = 3; @@ -99,6 +103,15 @@ pub enum NetworkEvent<E: EthSpec> { /// The message itself. message: PubsubMessage<E>, }, + /// A partial data column sidecar received via gossipsub partial protocol. + PartialDataColumnSidecar { + /// The peer from which we received this message. + source: PeerId, + /// The partial column data. + column: Box<PartialDataColumn<E>>, + /// The topic that this message was sent on. + topic: GossipTopic, + }, /// Inform the network to send a Status to this peer. StatusPeer(PeerId), NewListenAddr(Multiaddr), @@ -162,6 +175,7 @@ pub struct Network<E: EthSpec> { /// The interval for updating gossipsub scores update_gossipsub_scores: tokio::time::Interval, gossip_cache: GossipCache, + partial_column_header_tracker: PartialColumnHeaderTracker, /// This node's PeerId. pub local_peer_id: PeerId, } @@ -187,10 +201,9 @@ impl<E: EthSpec> Network<E> { // set up a collection of variables accessible outside of the network crate // Create an ENR or load from disk if appropriate - let next_fork_digest = ctx - .fork_context - .next_fork_digest() - .unwrap_or_else(|| ctx.fork_context.current_fork_digest()); + // Per [spec](https://github.com/ethereum/consensus-specs/blob/1baa05e71148b0975e28918ac6022d2256b56f4a/specs/fulu/p2p-interface.md?plain=1#L636-L637) + // `nfd` must be zero-valued when no next fork is scheduled. + let next_fork_digest = ctx.fork_context.next_fork_digest().unwrap_or_default(); let advertised_cgc = config .advertise_false_custody_group_count @@ -232,7 +245,7 @@ impl<E: EthSpec> Network<E> { config.network_load, ctx.fork_context.clone(), gossipsub_config_params, - ctx.chain_spec.seconds_per_slot, + ctx.chain_spec.get_slot_duration(), E::slots_per_epoch(), config.idontwant_message_size_threshold, ); @@ -240,13 +253,12 @@ impl<E: EthSpec> Network<E> { let score_settings = PeerScoreSettings::new(&ctx.chain_spec, gs_config.mesh_n()); let gossip_cache = { - let slot_duration = std::time::Duration::from_secs(ctx.chain_spec.seconds_per_slot); - let half_epoch = std::time::Duration::from_secs( - ctx.chain_spec.seconds_per_slot * E::slots_per_epoch() / 2, + let half_epoch = std::time::Duration::from_millis( + (ctx.chain_spec.get_slot_duration().as_millis() as u64) * E::slots_per_epoch() / 2, ); GossipCache::builder() - .beacon_block_timeout(slot_duration) + .beacon_block_timeout(ctx.chain_spec.get_slot_duration()) .aggregates_timeout(half_epoch) .attestation_timeout(half_epoch) .voluntary_exit_timeout(half_epoch * 2) @@ -507,6 +519,7 @@ impl<E: EthSpec> Network<E> { score_settings, update_gossipsub_scores, gossip_cache, + partial_column_header_tracker: PartialColumnHeaderTracker::new(), local_peer_id, }; @@ -574,6 +587,7 @@ impl<E: EthSpec> Network<E> { }; // attempt to connect to user-input libp2p nodes + // DEPRECATED: can be removed in v8.2.0./v9.0.0 for multiaddr in &config.libp2p_nodes { dial(multiaddr.clone()); } @@ -805,9 +819,18 @@ impl<E: EthSpec> Network<E> { .write() .insert(topic.clone()); + let partial = topic + .kind() + .use_partial_messages(self.network_globals.config.as_ref()); let topic: Topic = topic.into(); - match self.gossipsub_mut().subscribe(&topic) { + let subscribe_result = if partial { + self.gossipsub_mut().subscribe_partial(&topic, true) + } else { + self.gossipsub_mut().subscribe(&topic) + }; + + match subscribe_result { Err(e) => { warn!(%topic, error = ?e, "Failed to subscribe to topic"); false @@ -850,6 +873,16 @@ impl<E: EthSpec> Network<E> { "Attempted to publish duplicate message" ); } + PublishError::NoPeersSubscribedToTopic + if topic + .kind() + .use_partial_messages(self.network_globals.config.as_ref()) => + { + debug!( + kind = %topic.kind(), + "No peers supporting full messages" + ); + } ref e => { warn!( error = ?e, @@ -887,6 +920,66 @@ impl<E: EthSpec> Network<E> { } } + /// Publishes partial data column sidecars to the gossipsub network. + pub fn publish_partial( + &mut self, + columns: Vec<Arc<PartialDataColumn<E>>>, + header: Arc<PartialDataColumnHeader<E>>, + ) { + if !self.network_globals.config.enable_partial_columns { + return; + } + + debug!( + count = columns.len(), + "Sending partial data column sidecars" + ); + + for column in columns { + let subnet = + DataColumnSubnetId::from_column_index(column.index, &self.fork_context.spec); + let topic = GossipTopic::new( + GossipKind::DataColumnSidecar(subnet), + GossipEncoding::default(), + self.enr_fork_id.fork_digest, + ); + let header_sent_set = self + .partial_column_header_tracker + .get_for_block(column.block_root); + let partial_message = OutgoingPartialColumn::new(column, &header, header_sent_set); + let publish_topic: Topic = topic.clone().into(); + + if let Err(e) = self + .gossipsub_mut() + .publish_partial(publish_topic, partial_message) + { + match e { + PublishError::NoPeersSubscribedToTopic => { + debug!( + kind = %topic.kind(), + "No peers supporting partial messages" + ); + } + ref e => { + warn!( + error = ?e, + kind = %topic.kind(), + "Could not publish partial message" + ); + } + } + + // add to metrics + if let Some(v) = metrics::get_int_gauge( + &metrics::FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC, + &[&format!("{:?}", topic.kind())], + ) { + v.inc() + }; + } + } + } + /// Informs the gossipsub about the result of a message validation. /// If the message is valid it will get propagated by gossipsub. pub fn report_message_validation_result( @@ -919,6 +1012,29 @@ impl<E: EthSpec> Network<E> { ); } + /// Informs the gossipsub about the failure of a partial message validation. + pub fn report_partial_message_validation_failure( + &mut self, + propagation_source: PeerId, + topic: GossipTopic, + ) { + if let Some(client) = self + .network_globals + .peers + .read() + .peer_info(&propagation_source) + .map(|info| info.client().kind.as_ref()) + { + metrics::inc_counter_vec( + &metrics::GOSSIP_UNACCEPTED_MESSAGES_PER_CLIENT, + &[client, "reject"], + ) + } + + self.gossipsub_mut() + .report_invalid_partial(propagation_source, &TopicHash::from(Topic::from(topic))); + } + /// Updates the current gossipsub scoring parameters based on the validator count and current /// slot. pub fn update_gossipsub_parameters( @@ -1291,6 +1407,56 @@ impl<E: EthSpec> Network<E> { } } } + Event::Partial { + topic_hash, + peer_id, + group_id, + message, + .. + } => { + let topic = GossipTopic::decode(topic_hash.as_str()) + .inspect_err(|error| { + debug!( + topic = ?topic_hash, + error, + "Could not decode gossipsub partial message topic" + ); + // punish the peer + self.gossipsub_mut() + .report_invalid_partial(peer_id, &topic_hash); + }) + .ok()?; + + if let Some(message) = message { + match decode_partial::<E>(&topic, &group_id, &message) { + Err(error) => { + debug!( + topic = ?topic_hash, + error, + "Could not decode gossipsub partial message" + ); + //reject the message + self.gossipsub_mut() + .report_invalid_partial(peer_id, &topic_hash); + } + Ok(column) => { + debug!( + block_root = %column.block_root, + index = column.index, + %peer_id, + cells_present = %column.sidecar.cells_present_bitmap, + "Decoded partial message" + ); + // Notify the network + return Some(NetworkEvent::PartialDataColumnSidecar { + source: peer_id, + column: Box::new(column), + topic, + }); + } + } + } + } gossipsub::Event::Subscribed { peer_id, topic } => { if let Ok(topic) = GossipTopic::decode(topic.as_str()) { if let Some(subnet_id) = topic.subnet_id() { @@ -1525,6 +1691,28 @@ impl<E: EthSpec> Network<E> { request_type, }) } + RequestType::PayloadEnvelopesByRange(_) => { + metrics::inc_counter_vec( + &metrics::TOTAL_RPC_REQUESTS, + &["payload_envelopes_by_range"], + ); + Some(NetworkEvent::RequestReceived { + peer_id, + inbound_request_id, + request_type, + }) + } + RequestType::PayloadEnvelopesByRoot(_) => { + metrics::inc_counter_vec( + &metrics::TOTAL_RPC_REQUESTS, + &["payload_envelopes_by_root"], + ); + Some(NetworkEvent::RequestReceived { + peer_id, + inbound_request_id, + request_type, + }) + } RequestType::BlobsByRange(_) => { metrics::inc_counter_vec(&metrics::TOTAL_RPC_REQUESTS, &["blobs_by_range"]); Some(NetworkEvent::RequestReceived { @@ -1639,6 +1827,16 @@ impl<E: EthSpec> Network<E> { RpcSuccessResponse::BlocksByRoot(resp) => { self.build_response(id, peer_id, Response::BlocksByRoot(Some(resp))) } + RpcSuccessResponse::PayloadEnvelopesByRange(resp) => self.build_response( + id, + peer_id, + Response::PayloadEnvelopesByRange(Some(resp)), + ), + RpcSuccessResponse::PayloadEnvelopesByRoot(resp) => self.build_response( + id, + peer_id, + Response::PayloadEnvelopesByRoot(Some(resp)), + ), RpcSuccessResponse::BlobsByRoot(resp) => { self.build_response(id, peer_id, Response::BlobsByRoot(Some(resp))) } @@ -1673,6 +1871,12 @@ impl<E: EthSpec> Network<E> { let response = match termination { ResponseTermination::BlocksByRange => Response::BlocksByRange(None), ResponseTermination::BlocksByRoot => Response::BlocksByRoot(None), + ResponseTermination::PayloadEnvelopesByRange => { + Response::PayloadEnvelopesByRange(None) + } + ResponseTermination::PayloadEnvelopesByRoot => { + Response::PayloadEnvelopesByRoot(None) + } ResponseTermination::BlobsByRange => Response::BlobsByRange(None), ResponseTermination::BlobsByRoot => Response::BlobsByRoot(None), ResponseTermination::DataColumnsByRoot => Response::DataColumnsByRoot(None), @@ -1764,9 +1968,9 @@ impl<E: EthSpec> Network<E> { fn inject_upnp_event(&mut self, event: libp2p::upnp::Event) { match event { - libp2p::upnp::Event::NewExternalAddr(addr) => { - info!(%addr, "UPnP route established"); - let mut iter = addr.iter(); + libp2p::upnp::Event::NewExternalAddr { external_addr, .. } => { + info!(%external_addr, "UPnP route established"); + let mut iter = external_addr.iter(); let is_ip6 = { let addr = iter.next(); matches!(addr, Some(MProtocol::Ip6(_))) @@ -1781,7 +1985,7 @@ impl<E: EthSpec> Network<E> { } } _ => { - trace!(%addr, "UPnP address mapped multiaddr from unknown transport"); + trace!(%external_addr, "UPnP address mapped multiaddr from unknown transport"); } }, Some(multiaddr::Protocol::Tcp(tcp_port)) => { @@ -1790,11 +1994,11 @@ impl<E: EthSpec> Network<E> { } } _ => { - trace!(%addr, "UPnP address mapped multiaddr from unknown transport"); + trace!(%external_addr, "UPnP address mapped multiaddr from unknown transport"); } } } - libp2p::upnp::Event::ExpiredExternalAddr(_) => {} + libp2p::upnp::Event::ExpiredExternalAddr { .. } => {} libp2p::upnp::Event::GatewayNotFound => { info!("UPnP not available"); } @@ -1861,8 +2065,6 @@ impl<E: EthSpec> Network<E> { self.inject_upnp_event(e); None } - #[allow(unreachable_patterns)] - BehaviourEvent::ConnectionLimits(le) => libp2p::core::util::unreachable(le), }, SwarmEvent::ConnectionEstablished { .. } => None, SwarmEvent::ConnectionClosed { .. } => None, diff --git a/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs new file mode 100644 index 0000000000..bb588fe3d8 --- /dev/null +++ b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs @@ -0,0 +1,28 @@ +use crate::types::HeaderSentSet; +use lru::LruCache; +use parking_lot::Mutex; +use std::collections::HashSet; +use std::num::NonZeroUsize; +use std::sync::Arc; +use types::core::Hash256; + +const MAX_BLOCKS: NonZeroUsize = NonZeroUsize::new(4).unwrap(); + +pub struct PartialColumnHeaderTracker { + blocks: LruCache<Hash256, HeaderSentSet>, +} + +impl PartialColumnHeaderTracker { + pub fn new() -> Self { + PartialColumnHeaderTracker { + blocks: LruCache::new(MAX_BLOCKS), + } + } + + pub fn get_for_block(&mut self, hash: Hash256) -> HeaderSentSet { + Arc::clone( + self.blocks + .get_or_insert(hash, || Arc::new(Mutex::new(HashSet::new()))), + ) + } +} diff --git a/beacon_node/lighthouse_network/src/service/utils.rs b/beacon_node/lighthouse_network/src/service/utils.rs index 83011c0449..e000fba39e 100644 --- a/beacon_node/lighthouse_network/src/service/utils.rs +++ b/beacon_node/lighthouse_network/src/service/utils.rs @@ -3,11 +3,10 @@ use crate::rpc::{MetaData, MetaDataV2, MetaDataV3}; use crate::types::{EnrAttestationBitfield, EnrSyncCommitteeBitfield, GossipEncoding, GossipKind}; use crate::{GossipTopic, NetworkConfig}; use futures::future::Either; -use gossipsub; use libp2p::core::{multiaddr::Multiaddr, muxing::StreamMuxerBox, transport::Boxed}; use libp2p::identity::{Keypair, secp256k1}; use libp2p::metrics::Registry; -use libp2p::{PeerId, Transport, core, noise, yamux}; +use libp2p::{PeerId, Transport, core, gossipsub, noise, yamux}; use ssz::Decode; use std::collections::HashSet; use std::fs::File; @@ -273,6 +272,10 @@ pub(crate) fn create_whitelist_filter( add(AttesterSlashing); add(SignedContributionAndProof); add(BlsToExecutionChange); + add(ExecutionPayload); + add(ExecutionPayloadBid); + add(PayloadAttestation); + add(ProposerPreferences); add(LightClientFinalityUpdate); add(LightClientOptimisticUpdate); add(InclusionList); diff --git a/beacon_node/lighthouse_network/src/types/globals.rs b/beacon_node/lighthouse_network/src/types/globals.rs index f46eb05ceb..df8dbdc559 100644 --- a/beacon_node/lighthouse_network/src/types/globals.rs +++ b/beacon_node/lighthouse_network/src/types/globals.rs @@ -9,8 +9,8 @@ use network_utils::enr_ext::EnrExt; use parking_lot::RwLock; use std::collections::HashSet; use std::sync::Arc; -use tracing::error; -use types::data_column_custody_group::{compute_subnets_from_custody_group, get_custody_groups}; +use tracing::{debug, error}; +use types::data::{compute_subnets_from_custody_group, get_custody_groups}; use types::{ChainSpec, ColumnIndex, DataColumnSubnetId, EthSpec}; pub struct NetworkGlobals<E: EthSpec> { @@ -79,7 +79,7 @@ impl<E: EthSpec> NetworkGlobals<E> { sampling_subnets.extend(subnets); } - tracing::debug!( + debug!( cgc = custody_group_count, ?sampling_subnets, "Starting node with custody params" diff --git a/beacon_node/lighthouse_network/src/types/mod.rs b/beacon_node/lighthouse_network/src/types/mod.rs index eea8782b2d..d0173e5b9a 100644 --- a/beacon_node/lighthouse_network/src/types/mod.rs +++ b/beacon_node/lighthouse_network/src/types/mod.rs @@ -1,4 +1,5 @@ mod globals; +mod partial; mod pubsub; mod subnet; mod topics; @@ -13,7 +14,9 @@ pub type Enr = discv5::enr::Enr<discv5::enr::CombinedKey>; pub use eth2::lighthouse::sync_state::{BackFillState, CustodyBackFillState, SyncState}; pub use globals::NetworkGlobals; -pub use pubsub::{PubsubMessage, SnappyTransform}; +pub use partial::HeaderSentSet; +pub use partial::OutgoingPartialColumn; +pub use pubsub::{PubsubMessage, SnappyTransform, decode_partial}; pub use subnet::{Subnet, SubnetDiscovery}; pub use topics::{ GossipEncoding, GossipKind, GossipTopic, TopicConfig, all_topics_at_fork, diff --git a/beacon_node/lighthouse_network/src/types/partial.rs b/beacon_node/lighthouse_network/src/types/partial.rs new file mode 100644 index 0000000000..f25ce9ec36 --- /dev/null +++ b/beacon_node/lighthouse_network/src/types/partial.rs @@ -0,0 +1,503 @@ +use crate::PeerId; +use itertools::Itertools; +use libp2p::gossipsub::partial_messages::{Metadata, Partial, PartialAction, PartialError}; +use parking_lot::Mutex; +use ssz::{Decode, Encode}; +use std::collections::HashSet; +use std::fmt::Debug; +use std::sync::Arc; +use tracing::{debug, error}; +use types::core::{EthSpec, Hash256}; +use types::data::{ + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + PartialDataColumnSidecar, PartialDataColumnSidecarRef, +}; + +const PARTIAL_COLUMNS_VERSION_BYTE: u8 = 0; + +pub type HeaderSentSet = Arc<Mutex<HashSet<PeerId>>>; + +#[derive(Debug, Clone)] +pub struct OutgoingPartialColumn<E: EthSpec> { + partial_column: Arc<PartialDataColumn<E>>, + metadata: MaybeKnownMetadata<E>, + header_message: Vec<u8>, + header_sent_set: HeaderSentSet, +} + +impl<E: EthSpec> OutgoingPartialColumn<E> { + pub fn new( + partial_column: Arc<PartialDataColumn<E>>, + header: &PartialDataColumnHeader<E>, + header_sent_set: HeaderSentSet, + ) -> Self { + // For now, always request all cells + let mut requests = partial_column.sidecar.cells_present_bitmap.clone(); + for idx in 0..requests.len() { + requests + .set(idx, true) + .expect("Bound asserted via `len` above"); + } + let metadata = PartialDataColumnPartsMetadata::<E> { + available: partial_column.sidecar.cells_present_bitmap.clone(), + requests, + } + .into(); + + let header_message = PartialDataColumnSidecarRef { + cells_present_bitmap: CellBitmap::<E>::with_capacity( + partial_column.sidecar.cells_present_bitmap.len(), + ) + .expect("Taking length from bitmap with same bound"), + column: vec![], + kzg_proofs: vec![], + header: Some(header).into(), + } + .as_ssz_bytes(); + + OutgoingPartialColumn { + partial_column, + metadata, + header_message, + header_sent_set, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MaybeKnownMetadata<E: EthSpec> { + Unknown, + Known { + metadata: Box<PartialDataColumnPartsMetadata<E>>, + encoded: Vec<u8>, + }, +} + +impl<E: EthSpec> MaybeKnownMetadata<E> { + fn do_update( + &mut self, + received: PartialDataColumnPartsMetadata<E>, + ) -> Result<bool, PartialError> { + let MaybeKnownMetadata::Known { metadata, encoded } = self else { + *self = MaybeKnownMetadata::Known { + encoded: received.as_ssz_bytes(), + metadata: Box::new(received), + }; + return Ok(true); + }; + + if ![ + received.available.len(), + received.requests.len(), + metadata.available.len(), + metadata.requests.len(), + ] + .into_iter() + .all_equal() + { + return Err(PartialError::OutOfRange); + } + let new_available = metadata.available.union(&received.available); + let new_request = metadata.requests.union(&received.requests); + if metadata.available == new_available && metadata.requests == new_request { + return Ok(false); + } + metadata.available = new_available; + metadata.requests = new_request; + *encoded = metadata.as_ssz_bytes(); + Ok(true) + } +} + +impl<E: EthSpec> Metadata for MaybeKnownMetadata<E> { + fn as_slice(&self) -> &[u8] { + match self { + MaybeKnownMetadata::Unknown => &[], + MaybeKnownMetadata::Known { encoded, .. } => encoded, + } + } + + fn update(&mut self, data: &[u8]) -> Result<bool, PartialError> { + let received = PartialDataColumnPartsMetadata::from_ssz_bytes(data) + .map_err(|_| PartialError::InvalidFormat)?; + + self.do_update(received) + } + + fn update_from_data(&mut self, data: &[u8]) -> Result<(), PartialError> { + if data.is_empty() { + return Ok(()); + } + + let sidecar = PartialDataColumnSidecar::<E>::from_ssz_bytes(data) + .map_err(|_| PartialError::InvalidFormat)?; + + self.do_update(PartialDataColumnPartsMetadata { + available: sidecar.cells_present_bitmap.clone(), + requests: sidecar.cells_present_bitmap, + }) + .map(|_| ()) + } +} + +impl<E: EthSpec> From<PartialDataColumnPartsMetadata<E>> for MaybeKnownMetadata<E> { + fn from(metadata: PartialDataColumnPartsMetadata<E>) -> Self { + Self::Known { + encoded: metadata.as_ssz_bytes(), + metadata: Box::new(metadata), + } + } +} + +impl<E: EthSpec> Partial for OutgoingPartialColumn<E> { + fn group_id(&self) -> Vec<u8> { + let mut group_id = Vec::with_capacity(Hash256::len_bytes() + 1); + group_id.push(PARTIAL_COLUMNS_VERSION_BYTE); + group_id.extend_from_slice(self.partial_column.block_root.as_slice()); + group_id + } + + fn metadata(&self) -> Box<dyn Metadata> { + Box::new(self.metadata.clone()) + } + + fn partial_action_from_metadata( + &self, + peer_id: PeerId, + metadata: Option<&[u8]>, + ) -> Result<PartialAction, PartialError> { + match metadata { + None => { + // send the header-only messsage to the peer if we have not yet + let send = self.header_sent_set.lock().insert(peer_id).then(|| { + ( + self.header_message.clone(), + Box::new(MaybeKnownMetadata::<E>::Unknown) as Box<dyn Metadata>, + ) + }); + debug!( + peer=%peer_id, + group_id=%self.partial_column.block_root, + column_index=self.partial_column.index, + sending_header=send.is_some(), + "Partial send: No metadata" + ); + + Ok(PartialAction { need: false, send }) + } + Some([]) => Ok(PartialAction { + need: false, + send: None, + }), + Some(metadata) => { + // The peer is apparently aware of the header, make sure we track that: + self.header_sent_set.lock().insert(peer_id); + + let peer_metadata = PartialDataColumnPartsMetadata::<E>::from_ssz_bytes(metadata) + .map_err(|_| PartialError::InvalidFormat)?; + let expected_len = self.partial_column.sidecar.cells_present_bitmap.len(); + if peer_metadata.available.len() != expected_len + || peer_metadata.requests.len() != expected_len + { + return Err(PartialError::InvalidFormat); + } + + let need = !peer_metadata + .available + .is_subset(&self.partial_column.sidecar.cells_present_bitmap); + let want = peer_metadata.requests.difference(&peer_metadata.available); + + let send = self + .partial_column + .sidecar + .filter(|idx| want.get(idx).expect("Bound checked above")) + .map_err(|err| { + error!(?err, "Unexpected error filtering sidecar"); + PartialError::InvalidFormat + })? + .map(|sidecar| { + debug!( + peer=%peer_id, + group_id=%self.partial_column.block_root, + column_index=self.partial_column.index, + metadata=%peer_metadata, + sending=%sidecar.cells_present_bitmap, + "Partial send: Sending" + ); + ( + sidecar.as_ssz_bytes(), + Box::new(MaybeKnownMetadata::<E>::from( + PartialDataColumnPartsMetadata { + available: peer_metadata + .available + .union(&sidecar.cells_present_bitmap), + requests: peer_metadata + .requests + .union(&sidecar.cells_present_bitmap), + }, + )) as Box<dyn Metadata + 'static>, + ) + }); + + if send.is_none() { + debug!( + peer=%peer_id, + group_id=%self.partial_column.block_root, + column_index=self.partial_column.index, + metadata=%peer_metadata, + "Partial send: Nothing to send" + ); + } + + Ok(PartialAction { need, send }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Signature; + use fixed_bytes::FixedBytesExtended; + use libp2p::identity::Keypair; + use ssz_types::FixedVector; + use types::block::{BeaconBlockHeader, SignedBeaconBlockHeader}; + use types::core::{MinimalEthSpec, Slot}; + use types::data::PartialDataColumnHeader; + + type E = MinimalEthSpec; + + fn make_cell(marker: u8) -> types::Cell<E> { + let mut cell = types::Cell::<E>::default(); + cell[0] = marker; + cell + } + + fn make_header(num_commitments: usize) -> PartialDataColumnHeader<E> { + PartialDataColumnHeader { + kzg_commitments: vec![types::KzgCommitment([0u8; 48]); num_commitments] + .try_into() + .unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + } + } + + fn make_partial_column( + block_root: Hash256, + total_blobs: usize, + present_indices: &[usize], + ) -> Arc<PartialDataColumn<E>> { + let mut bitmap = CellBitmap::<E>::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + Arc::new(PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: present_indices + .iter() + .map(|&idx| make_cell(idx as u8)) + .collect::<Vec<_>>() + .try_into() + .unwrap(), + kzg_proofs: present_indices + .iter() + .map(|_| types::KzgProof::empty()) + .collect::<Vec<_>>() + .try_into() + .unwrap(), + header: None.into(), + }, + }) + } + + fn random_peer_id() -> PeerId { + let keypair = Keypair::generate_ed25519(); + PeerId::from(keypair.public()) + } + + // -- MaybeKnownMetadata tests -- + + #[test] + fn update_from_unknown_initializes() { + let mut meta = MaybeKnownMetadata::<E>::Unknown; + let mut bitmap = CellBitmap::<E>::with_capacity(4).unwrap(); + bitmap.set(0, true).unwrap(); + let received = PartialDataColumnPartsMetadata { + available: bitmap.clone(), + requests: bitmap, + }; + let changed = meta.do_update(received).unwrap(); + assert!(changed); + assert!(matches!(meta, MaybeKnownMetadata::Known { .. })); + } + + #[test] + fn update_unions_bitmaps() { + let mut bitmap1 = CellBitmap::<E>::with_capacity(4).unwrap(); + bitmap1.set(0, true).unwrap(); + let mut meta: MaybeKnownMetadata<E> = PartialDataColumnPartsMetadata { + available: bitmap1.clone(), + requests: bitmap1, + } + .into(); + + let mut bitmap2 = CellBitmap::<E>::with_capacity(4).unwrap(); + bitmap2.set(1, true).unwrap(); + let changed = meta + .do_update(PartialDataColumnPartsMetadata { + available: bitmap2.clone(), + requests: bitmap2, + }) + .unwrap(); + assert!(changed); + + if let MaybeKnownMetadata::Known { metadata, .. } = &meta { + assert!(metadata.available.get(0).unwrap()); + assert!(metadata.available.get(1).unwrap()); + assert!(!metadata.available.get(2).unwrap()); + } else { + panic!("Expected Known metadata"); + } + } + + #[test] + fn update_returns_false_when_no_change() { + let mut bitmap = CellBitmap::<E>::with_capacity(4).unwrap(); + bitmap.set(0, true).unwrap(); + bitmap.set(1, true).unwrap(); + let mut meta: MaybeKnownMetadata<E> = PartialDataColumnPartsMetadata { + available: bitmap.clone(), + requests: bitmap.clone(), + } + .into(); + + // Update with a subset + let mut subset = CellBitmap::<E>::with_capacity(4).unwrap(); + subset.set(0, true).unwrap(); + let changed = meta + .do_update(PartialDataColumnPartsMetadata { + available: subset.clone(), + requests: subset, + }) + .unwrap(); + assert!(!changed); + } + + #[test] + fn update_rejects_mismatched_lengths() { + let mut bitmap4 = CellBitmap::<E>::with_capacity(4).unwrap(); + bitmap4.set(0, true).unwrap(); + let mut meta: MaybeKnownMetadata<E> = PartialDataColumnPartsMetadata { + available: bitmap4.clone(), + requests: bitmap4, + } + .into(); + + let mut bitmap6 = CellBitmap::<E>::with_capacity(6).unwrap(); + bitmap6.set(0, true).unwrap(); + let result = meta.do_update(PartialDataColumnPartsMetadata { + available: bitmap6.clone(), + requests: bitmap6, + }); + assert!(result.is_err()); + } + + // -- OutgoingPartialColumn::partial_action_from_metadata tests -- + + #[test] + fn no_metadata_sends_header_once() { + let root = Hash256::repeat_byte(1); + let header = make_header(4); + let partial = make_partial_column(root, 4, &[0, 1]); + let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + + let peer = random_peer_id(); + + // First call with no metadata → sends header + let action = outgoing.partial_action_from_metadata(peer, None).unwrap(); + assert!(action.send.is_some()); + + // Second call for same peer → no send + let action2 = outgoing.partial_action_from_metadata(peer, None).unwrap(); + assert!(action2.send.is_none()); + } + + #[test] + fn metadata_filters_cells_to_send() { + let root = Hash256::repeat_byte(1); + let header = make_header(4); + // We have cells [0, 2, 3] + let partial = make_partial_column(root, 4, &[0, 2, 3]); + let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + + let peer = random_peer_id(); + + // Peer has [0, 1], wants [0, 1, 2, 3] + let mut peer_available = CellBitmap::<E>::with_capacity(4).unwrap(); + peer_available.set(0, true).unwrap(); + peer_available.set(1, true).unwrap(); + let mut peer_request = CellBitmap::<E>::with_capacity(4).unwrap(); + for i in 0..4 { + peer_request.set(i, true).unwrap(); + } + let peer_meta = PartialDataColumnPartsMetadata::<E> { + available: peer_available, + requests: peer_request, + }; + let encoded = peer_meta.as_ssz_bytes(); + + let action = outgoing + .partial_action_from_metadata(peer, Some(&encoded)) + .unwrap(); + // We should send cells [2, 3] (want = request - available = [2,3], and we have [0,2,3]) + assert!(action.send.is_some()); + } + + #[test] + fn metadata_sets_need_when_peer_has_unknown_cells() { + let root = Hash256::repeat_byte(1); + let header = make_header(4); + // We have cells [0] + let partial = make_partial_column(root, 4, &[0]); + let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + + let peer = random_peer_id(); + + // Peer has [0, 1, 2] — cells [1, 2] are unknown to us + let mut peer_available = CellBitmap::<E>::with_capacity(4).unwrap(); + peer_available.set(0, true).unwrap(); + peer_available.set(1, true).unwrap(); + peer_available.set(2, true).unwrap(); + let peer_meta = PartialDataColumnPartsMetadata::<E> { + available: peer_available.clone(), + requests: peer_available, + }; + let encoded = peer_meta.as_ssz_bytes(); + + let action = outgoing + .partial_action_from_metadata(peer, Some(&encoded)) + .unwrap(); + assert!(action.need); + } +} diff --git a/beacon_node/lighthouse_network/src/types/pubsub.rs b/beacon_node/lighthouse_network/src/types/pubsub.rs index 177d8ec352..b6c7f8361c 100644 --- a/beacon_node/lighthouse_network/src/types/pubsub.rs +++ b/beacon_node/lighthouse_network/src/types/pubsub.rs @@ -1,20 +1,22 @@ //! Handles the encoding and decoding of pubsub messages. -use crate::TopicHash; use crate::types::{GossipEncoding, GossipKind, GossipTopic}; +use gossipsub::TopicHash; use snap::raw::{Decoder, Encoder, decompress_len}; use ssz::{Decode, Encode}; use std::io::{Error, ErrorKind}; use std::sync::Arc; use types::{ AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, BlobSidecar, - DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkContext, ForkName, - LightClientFinalityUpdate, LightClientOptimisticUpdate, ProposerSlashing, - SignedAggregateAndProof, SignedAggregateAndProofBase, SignedAggregateAndProofElectra, - SignedBeaconBlock, SignedBeaconBlockAltair, SignedBeaconBlockBase, SignedBeaconBlockBellatrix, + DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkContext, ForkName, Hash256, + LightClientFinalityUpdate, LightClientOptimisticUpdate, PartialDataColumn, + PartialDataColumnSidecar, PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, + SignedAggregateAndProofBase, SignedAggregateAndProofElectra, SignedBeaconBlock, + SignedBeaconBlockAltair, SignedBeaconBlockBase, SignedBeaconBlockBellatrix, SignedBeaconBlockCapella, SignedBeaconBlockDeneb, SignedBeaconBlockEip7805, SignedBeaconBlockElectra, SignedBeaconBlockFulu, SignedBeaconBlockGloas, - SignedBlsToExecutionChange, SignedContributionAndProof, SignedInclusionList, + SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, + SignedExecutionPayloadEnvelope, SignedInclusionList, SignedProposerPreferences, SignedVoluntaryExit, SingleAttestation, SubnetId, SyncCommitteeMessage, SyncSubnetId, }; @@ -42,6 +44,14 @@ pub enum PubsubMessage<E: EthSpec> { SyncCommitteeMessage(Box<(SyncSubnetId, SyncCommitteeMessage)>), /// Gossipsub message for BLS to execution change messages. BlsToExecutionChange(Box<SignedBlsToExecutionChange>), + /// Gossipsub message providing notification of a signed execution payload envelope. + ExecutionPayload(Box<SignedExecutionPayloadEnvelope<E>>), + /// Gossipsub message providing notification of a payload attestation message. + PayloadAttestation(Box<PayloadAttestationMessage>), + /// Gossipsub message providing notification of a signed execution payload bid. + ExecutionPayloadBid(Box<SignedExecutionPayloadBid<E>>), + /// Gossipsub message providing notification of signed proposer preferences. + ProposerPreferences(Box<SignedProposerPreferences>), /// Gossipsub message providing notification of a light client finality update. LightClientFinalityUpdate(Box<LightClientFinalityUpdate<E>>), /// Gossipsub message providing notification of a light client optimistic update. @@ -147,6 +157,10 @@ impl<E: EthSpec> PubsubMessage<E> { PubsubMessage::SignedContributionAndProof(_) => GossipKind::SignedContributionAndProof, PubsubMessage::SyncCommitteeMessage(data) => GossipKind::SyncCommitteeMessage(data.0), PubsubMessage::BlsToExecutionChange(_) => GossipKind::BlsToExecutionChange, + PubsubMessage::ExecutionPayload(_) => GossipKind::ExecutionPayload, + PubsubMessage::PayloadAttestation(_) => GossipKind::PayloadAttestation, + PubsubMessage::ExecutionPayloadBid(_) => GossipKind::ExecutionPayloadBid, + PubsubMessage::ProposerPreferences(_) => GossipKind::ProposerPreferences, PubsubMessage::LightClientFinalityUpdate(_) => GossipKind::LightClientFinalityUpdate, PubsubMessage::LightClientOptimisticUpdate(_) => { GossipKind::LightClientOptimisticUpdate @@ -283,7 +297,7 @@ impl<E: EthSpec> PubsubMessage<E> { match fork_context.get_fork_from_context_bytes(gossip_topic.fork_digest) { Some(fork) if fork.fulu_enabled() => { let col_sidecar = Arc::new( - DataColumnSidecar::from_ssz_bytes(data) + DataColumnSidecar::from_ssz_bytes_for_fork(data, *fork) .map_err(|e| format!("{:?}", e))?, ); Ok(PubsubMessage::DataColumnSidecar(Box::new(( @@ -356,6 +370,35 @@ impl<E: EthSpec> PubsubMessage<E> { bls_to_execution_change, ))) } + GossipKind::ExecutionPayload => { + let execution_payload_envelope = + SignedExecutionPayloadEnvelope::from_ssz_bytes(data) + .map_err(|e| format!("{:?}", e))?; + Ok(PubsubMessage::ExecutionPayload(Box::new( + execution_payload_envelope, + ))) + } + GossipKind::ExecutionPayloadBid => { + let execution_payload_bid = SignedExecutionPayloadBid::from_ssz_bytes(data) + .map_err(|e| format!("{:?}", e))?; + Ok(PubsubMessage::ExecutionPayloadBid(Box::new( + execution_payload_bid, + ))) + } + GossipKind::PayloadAttestation => { + let payload_attestation = PayloadAttestationMessage::from_ssz_bytes(data) + .map_err(|e| format!("{:?}", e))?; + Ok(PubsubMessage::PayloadAttestation(Box::new( + payload_attestation, + ))) + } + GossipKind::ProposerPreferences => { + let proposer_preferences = SignedProposerPreferences::from_ssz_bytes(data) + .map_err(|e| format!("{:?}", e))?; + Ok(PubsubMessage::ProposerPreferences(Box::new( + proposer_preferences, + ))) + } GossipKind::LightClientFinalityUpdate => { let light_client_finality_update = match fork_context .get_fork_from_context_bytes(gossip_topic.fork_digest) @@ -441,6 +484,10 @@ impl<E: EthSpec> PubsubMessage<E> { PubsubMessage::SignedContributionAndProof(data) => data.as_ssz_bytes(), PubsubMessage::SyncCommitteeMessage(data) => data.1.as_ssz_bytes(), PubsubMessage::BlsToExecutionChange(data) => data.as_ssz_bytes(), + PubsubMessage::ExecutionPayload(data) => data.as_ssz_bytes(), + PubsubMessage::PayloadAttestation(data) => data.as_ssz_bytes(), + PubsubMessage::ExecutionPayloadBid(data) => data.as_ssz_bytes(), + PubsubMessage::ProposerPreferences(data) => data.as_ssz_bytes(), PubsubMessage::LightClientFinalityUpdate(data) => data.as_ssz_bytes(), PubsubMessage::LightClientOptimisticUpdate(data) => data.as_ssz_bytes(), PubsubMessage::InclusionList(data) => data.as_ssz_bytes(), @@ -448,6 +495,35 @@ impl<E: EthSpec> PubsubMessage<E> { } } +/// Decodes incoming partial data column sidecar from gossipsub partial protocol. +/// Note: Currently, data columns are the only supported partial messages. In future this could +/// return an enum. +pub fn decode_partial<E: EthSpec>( + topic: &GossipTopic, + group: &[u8], + data: &[u8], +) -> Result<PartialDataColumn<E>, String> { + match topic.kind() { + GossipKind::DataColumnSidecar(id) => { + if group.first() != Some(&0) { + return Err(format!("Unknown data column format: {:?}", group.first())); + } + let block_root = Hash256::from_ssz_bytes(&group[1..]) + .map_err(|e| format!("Error decoding group: {:?}", e))?; + let sidecar = PartialDataColumnSidecar::from_ssz_bytes(data) + .map_err(|e| format!("Error decoding sidecar: {:?}", e))?; + let data_column = PartialDataColumn { + block_root, + // Partial messages are spec'd under the assumption that there is one column per subnet. + index: **id, + sidecar, + }; + Ok(data_column) + } + other => Err(format!("Partial message unsupported for topic: {other}")), + } +} + impl<E: EthSpec> std::fmt::Display for PubsubMessage<E> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -467,7 +543,7 @@ impl<E: EthSpec> std::fmt::Display for PubsubMessage<E> { f, "DataColumnSidecar: slot: {}, column index: {}", data.1.slot(), - data.1.index, + data.1.index(), ), PubsubMessage::AggregateAndProofAttestation(att) => write!( f, @@ -497,6 +573,38 @@ impl<E: EthSpec> std::fmt::Display for PubsubMessage<E> { data.message.validator_index, data.message.to_execution_address ) } + PubsubMessage::ExecutionPayload(data) => { + write!( + f, + "Signed Execution Payload Envelope: slot: {:?}, beacon block root: {:?}", + data.slot(), + data.beacon_block_root() + ) + } + PubsubMessage::PayloadAttestation(data) => { + write!( + f, + "Payload Attestation Message: slot: {:?}, beacon block root: {:?}, payload present: {:?}, blob data available: {:?}", + data.data.slot, + data.data.beacon_block_root, + data.data.payload_present, + data.data.blob_data_available + ) + } + PubsubMessage::ExecutionPayloadBid(data) => { + write!( + f, + "Execution payload bid: slot: {:?} value: {:?}", + data.message.slot, data.message.value + ) + } + PubsubMessage::ProposerPreferences(data) => { + write!( + f, + "Proposer preferences: slot: {:?}, validator_index: {:?}", + data.message.proposal_slot, data.message.validator_index + ) + } PubsubMessage::LightClientFinalityUpdate(_data) => { write!(f, "Light CLient Finality Update") } diff --git a/beacon_node/lighthouse_network/src/types/topics.rs b/beacon_node/lighthouse_network/src/types/topics.rs index 692fa2ba58..4c508cc1f0 100644 --- a/beacon_node/lighthouse_network/src/types/topics.rs +++ b/beacon_node/lighthouse_network/src/types/topics.rs @@ -1,11 +1,17 @@ -use gossipsub::{IdentTopic as Topic, TopicHash}; +use libp2p::gossipsub::{IdentTopic as Topic, TopicHash}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use strum::AsRefStr; use typenum::Unsigned; -use types::{ChainSpec, DataColumnSubnetId, EthSpec, ForkName, SubnetId, SyncSubnetId}; +use types::{ + ChainSpec, EthSpec, + attestation::SubnetId, + data::{DataColumnSubnetId, all_data_column_sidecar_subnets_from_spec}, + fork::ForkName, + sync_committee::SyncSubnetId, +}; -use crate::Subnet; +use crate::{NetworkConfig, Subnet}; /// The gossipsub topic names. // These constants form a topic name of the form /TOPIC_PREFIX/TOPIC/ENCODING_POSTFIX @@ -23,6 +29,10 @@ pub const ATTESTER_SLASHING_TOPIC: &str = "attester_slashing"; pub const SIGNED_CONTRIBUTION_AND_PROOF_TOPIC: &str = "sync_committee_contribution_and_proof"; pub const SYNC_COMMITTEE_PREFIX_TOPIC: &str = "sync_committee_"; pub const BLS_TO_EXECUTION_CHANGE_TOPIC: &str = "bls_to_execution_change"; +pub const EXECUTION_PAYLOAD: &str = "execution_payload"; +pub const EXECUTION_PAYLOAD_BID: &str = "execution_payload_bid"; +pub const PAYLOAD_ATTESTATION: &str = "payload_attestation_message"; +pub const PROPOSER_PREFERENCES: &str = "proposer_preferences"; pub const LIGHT_CLIENT_FINALITY_UPDATE: &str = "light_client_finality_update"; pub const LIGHT_CLIENT_OPTIMISTIC_UPDATE: &str = "light_client_optimistic_update"; pub const INCLUSION_LIST_TOPIC: &str = "inclusion_list"; @@ -90,6 +100,13 @@ pub fn core_topics_to_subscribe<E: EthSpec>( } } + if fork_name.gloas_enabled() { + topics.push(GossipKind::ExecutionPayload); + topics.push(GossipKind::ExecutionPayloadBid); + topics.push(GossipKind::PayloadAttestation); + topics.push(GossipKind::ProposerPreferences); + } + topics } @@ -113,6 +130,10 @@ pub fn is_fork_non_core_topic(topic: &GossipTopic, _fork_name: ForkName) -> bool | GossipKind::AttesterSlashing | GossipKind::SignedContributionAndProof | GossipKind::BlsToExecutionChange + | GossipKind::ExecutionPayload + | GossipKind::ExecutionPayloadBid + | GossipKind::PayloadAttestation + | GossipKind::ProposerPreferences | GossipKind::LightClientFinalityUpdate | GossipKind::LightClientOptimisticUpdate | GossipKind::InclusionList => false, @@ -121,7 +142,7 @@ pub fn is_fork_non_core_topic(topic: &GossipTopic, _fork_name: ForkName) -> bool pub fn all_topics_at_fork<E: EthSpec>(fork: ForkName, spec: &ChainSpec) -> Vec<GossipKind> { // Compute the worst case of all forks - let sampling_subnets = HashSet::from_iter(spec.all_data_column_sidecar_subnets()); + let sampling_subnets = HashSet::from_iter(all_data_column_sidecar_subnets_from_spec(spec)); let opts = TopicConfig { enable_light_client_server: true, subscribe_all_subnets: true, @@ -171,6 +192,14 @@ pub enum GossipKind { SyncCommitteeMessage(SyncSubnetId), /// Topic for validator messages which change their withdrawal address. BlsToExecutionChange, + /// Topic for signed execution payload envelopes. + ExecutionPayload, + /// Topic for payload attestation messages. + PayloadAttestation, + /// Topic for signed execution payload bids. + ExecutionPayloadBid, + /// Topic for signed proposer preferences. + ProposerPreferences, /// Topic for publishing finality updates for light clients. LightClientFinalityUpdate, /// Topic for publishing optimistic updates for light clients. @@ -179,6 +208,15 @@ pub enum GossipKind { InclusionList, } +impl GossipKind { + pub fn use_partial_messages(&self, config: &NetworkConfig) -> bool { + match self { + GossipKind::DataColumnSidecar(_) => config.enable_partial_columns, + _ => false, + } + } +} + impl std::fmt::Display for GossipKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -257,6 +295,10 @@ impl GossipTopic { PROPOSER_SLASHING_TOPIC => GossipKind::ProposerSlashing, ATTESTER_SLASHING_TOPIC => GossipKind::AttesterSlashing, BLS_TO_EXECUTION_CHANGE_TOPIC => GossipKind::BlsToExecutionChange, + EXECUTION_PAYLOAD => GossipKind::ExecutionPayload, + EXECUTION_PAYLOAD_BID => GossipKind::ExecutionPayloadBid, + PAYLOAD_ATTESTATION => GossipKind::PayloadAttestation, + PROPOSER_PREFERENCES => GossipKind::ProposerPreferences, LIGHT_CLIENT_FINALITY_UPDATE => GossipKind::LightClientFinalityUpdate, LIGHT_CLIENT_OPTIMISTIC_UPDATE => GossipKind::LightClientOptimisticUpdate, INCLUSION_LIST_TOPIC => GossipKind::InclusionList, @@ -323,6 +365,10 @@ impl std::fmt::Display for GossipTopic { format!("{}{}", DATA_COLUMN_SIDECAR_PREFIX, *column_subnet_id) } GossipKind::BlsToExecutionChange => BLS_TO_EXECUTION_CHANGE_TOPIC.into(), + GossipKind::ExecutionPayload => EXECUTION_PAYLOAD.into(), + GossipKind::PayloadAttestation => PAYLOAD_ATTESTATION.into(), + GossipKind::ExecutionPayloadBid => EXECUTION_PAYLOAD_BID.into(), + GossipKind::ProposerPreferences => PROPOSER_PREFERENCES.into(), GossipKind::LightClientFinalityUpdate => LIGHT_CLIENT_FINALITY_UPDATE.into(), GossipKind::LightClientOptimisticUpdate => LIGHT_CLIENT_OPTIMISTIC_UPDATE.into(), GossipKind::InclusionList => INCLUSION_LIST_TOPIC.into(), diff --git a/beacon_node/lighthouse_network/tests/rpc_tests.rs b/beacon_node/lighthouse_network/tests/rpc_tests.rs index 599fcd242b..65b03189d4 100644 --- a/beacon_node/lighthouse_network/tests/rpc_tests.rs +++ b/beacon_node/lighthouse_network/tests/rpc_tests.rs @@ -5,8 +5,12 @@ use crate::common::spec_with_all_forks_enabled; use crate::common::{Protocol, build_tracing_subscriber}; use bls::Signature; use fixed_bytes::FixedBytesExtended; +use libp2p::PeerId; use lighthouse_network::rpc::{RequestType, methods::*}; -use lighthouse_network::service::api_types::AppRequestId; +use lighthouse_network::service::api_types::{ + AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, ComponentsByRangeRequestId, + DataColumnsByRangeRequestId, DataColumnsByRangeRequester, RangeRequestId, SyncRequestId, +}; use lighthouse_network::{NetworkEvent, ReportSource, Response}; use ssz::Encode; use ssz_types::{RuntimeVariableList, VariableList}; @@ -14,12 +18,12 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::runtime::Runtime; use tokio::time::sleep; -use tracing::{Instrument, debug, error, info_span, warn}; +use tracing::{Instrument, debug, error, info, info_span, warn}; use types::{ BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BeaconBlockBellatrix, BeaconBlockHeader, - BlobSidecar, ChainSpec, DataColumnSidecar, DataColumnsByRootIdentifier, EmptyBlock, Epoch, - EthSpec, ForkName, Hash256, KzgCommitment, KzgProof, MinimalEthSpec, SignedBeaconBlock, - SignedBeaconBlockHeader, Slot, + BlobSidecar, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, + DataColumnsByRootIdentifier, EmptyBlock, Epoch, EthSpec, ForkName, Hash256, KzgCommitment, + KzgProof, MinimalEthSpec, SignedBeaconBlock, SignedBeaconBlockHeader, Slot, }; type E = MinimalEthSpec; @@ -42,8 +46,10 @@ fn bellatrix_block_small(spec: &ChainSpec) -> BeaconBlock<E> { /// Hence, we generate a bellatrix block just greater than `MAX_RPC_SIZE` to test rejection on the rpc layer. fn bellatrix_block_large(spec: &ChainSpec) -> BeaconBlock<E> { let mut block = BeaconBlockBellatrix::<E>::empty(spec); + // 11,000 × 1KB ≈ 11MB, just above the 10MB max_payload_size. + // Previously used 100,000 txs (~100MB) which caused hangs and timeouts. let tx = VariableList::try_from(vec![0; 1024]).unwrap(); - let txs = VariableList::try_from(std::iter::repeat_n(tx, 100000).collect::<Vec<_>>()).unwrap(); + let txs = VariableList::try_from(std::iter::repeat_n(tx, 11000).collect::<Vec<_>>()).unwrap(); block.body.execution_payload.execution_payload.transactions = txs; @@ -133,16 +139,10 @@ fn test_tcp_status_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - debug!("Receiver Received"); - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } + } if request_type == rpc_request => { + // send the response + debug!("Receiver Received"); + receiver.send_response(peer_id, inbound_request_id, rpc_response.clone()); } _ => {} // Ignore other events } @@ -263,34 +263,33 @@ fn test_tcp_blocks_by_range_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - for i in 0..messages_to_send { - // Send first third of responses as base blocks, - // second as altair and third as bellatrix. - let rpc_response = if i < 2 { - rpc_response_base.clone() - } else if i < 4 { - rpc_response_altair.clone() - } else { - rpc_response_bellatrix_small.clone() - }; - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + for i in 0..messages_to_send { + // Send first third of responses as base blocks, + // second as altair and third as bellatrix. + let rpc_response = if i < 2 { + rpc_response_base.clone() + } else if i < 4 { + rpc_response_altair.clone() + } else { + rpc_response_bellatrix_small.clone() + }; receiver.send_response( peer_id, inbound_request_id, - Response::BlocksByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); } + _ => {} // Ignore other events } } @@ -398,26 +397,24 @@ fn test_blobs_by_range_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - for _ in 0..messages_to_send { - // Send first third of responses as base blocks, - // second as altair and third as bellatrix. - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + for _ in 0..messages_to_send { + // Send first third of responses as base blocks, + // second as altair and third as bellatrix. receiver.send_response( peer_id, inbound_request_id, - Response::BlobsByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlobsByRange(None), + ); } _ => {} // Ignore other events } @@ -506,25 +503,23 @@ fn test_tcp_blocks_by_range_over_limit() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - for _ in 0..messages_to_send { - let rpc_response = rpc_response_bellatrix_large.clone(); - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + for _ in 0..messages_to_send { + let rpc_response = rpc_response_bellatrix_large.clone(); receiver.send_response( peer_id, inbound_request_id, - Response::BlocksByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); } _ => {} // Ignore other events } @@ -644,12 +639,10 @@ fn test_tcp_blocks_by_range_chunked_rpc_terminates_correctly() { request_type, }, _, - )) => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - message_info = Some((peer_id, inbound_request_id)); - } + )) if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + message_info = Some((peer_id, inbound_request_id)); } futures::future::Either::Right((_, _)) => {} // The timeout hit, send messages if required _ => continue, @@ -764,25 +757,23 @@ fn test_tcp_blocks_by_range_single_empty_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); - for _ in 1..=messages_to_send { - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + for _ in 1..=messages_to_send { receiver.send_response( peer_id, inbound_request_id, - Response::BlocksByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); } _ => {} // Ignore other events } @@ -911,31 +902,29 @@ fn test_tcp_blocks_by_root_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - debug!("Receiver got request"); + } if request_type == rpc_request => { + // send the response + debug!("Receiver got request"); - for i in 0..messages_to_send { - // Send equal base, altair and bellatrix blocks - let rpc_response = if i < 2 { - rpc_response_base.clone() - } else if i < 4 { - rpc_response_altair.clone() - } else { - rpc_response_bellatrix_small.clone() - }; - receiver.send_response(peer_id, inbound_request_id, rpc_response); - debug!("Sending message"); - } - // send the stream termination - receiver.send_response( - peer_id, - inbound_request_id, - Response::BlocksByRange(None), - ); - debug!("Send stream term"); + for i in 0..messages_to_send { + // Send equal base, altair and bellatrix blocks + let rpc_response = if i < 2 { + rpc_response_base.clone() + } else if i < 4 { + rpc_response_altair.clone() + } else { + rpc_response_bellatrix_small.clone() + }; + receiver.send_response(peer_id, inbound_request_id, rpc_response); + debug!("Sending message"); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); + debug!("Send stream term"); } _ => {} // Ignore other events } @@ -952,9 +941,7 @@ fn test_tcp_blocks_by_root_chunked_rpc() { }) } -#[test] -#[allow(clippy::single_match)] -fn test_tcp_columns_by_root_chunked_rpc() { +fn test_tcp_columns_by_root_chunked_rpc_for_fork(fork_name: ForkName) { // Set up the logging. let log_level = "debug"; let enable_logging = true; @@ -963,14 +950,17 @@ fn test_tcp_columns_by_root_chunked_rpc() { let messages_to_send = 32 * num_of_columns; let spec = Arc::new(spec_with_all_forks_enabled()); - let current_fork_name = ForkName::Fulu; + let slot = spec + .fork_epoch(fork_name) + .expect("fork must be scheduled") + .start_slot(E::slots_per_epoch()); let rt = Arc::new(Runtime::new().unwrap()); // get sender/receiver rt.block_on(async { let (mut sender, mut receiver) = common::build_node_pair( Arc::downgrade(&rt), - current_fork_name, + fork_name, spec.clone(), Protocol::Tcp, false, @@ -980,7 +970,7 @@ fn test_tcp_columns_by_root_chunked_rpc() { // DataColumnsByRootRequest Request - let max_request_blocks = spec.max_request_blocks(current_fork_name); + let max_request_blocks = spec.max_request_blocks(fork_name); let req = DataColumnsByRootRequest::new( vec![ DataColumnsByRootIdentifier { @@ -999,7 +989,7 @@ fn test_tcp_columns_by_root_chunked_rpc() { let req_decoded = DataColumnsByRootRequest { data_column_ids: <RuntimeVariableList<DataColumnsByRootIdentifier<E>>>::from_ssz_bytes( &req_bytes, - spec.max_request_blocks(current_fork_name), + spec.max_request_blocks(fork_name), ) .unwrap(), }; @@ -1007,30 +997,43 @@ fn test_tcp_columns_by_root_chunked_rpc() { let rpc_request = RequestType::DataColumnsByRoot(req); // DataColumnsByRoot Response - let data_column = Arc::new(DataColumnSidecar { - index: 1, - signed_block_header: SignedBeaconBlockHeader { - message: BeaconBlockHeader { - slot: 320u64.into(), - proposer_index: 1, - parent_root: Hash256::zero(), - state_root: Hash256::zero(), - body_root: Hash256::zero(), + let data_column = if fork_name.gloas_enabled() { + Arc::new(DataColumnSidecar::Gloas(DataColumnSidecarGloas { + index: 1, + slot, + beacon_block_root: Hash256::zero(), + + column: vec![vec![0; E::bytes_per_cell()].try_into().unwrap()] + .try_into() + .unwrap(), + kzg_proofs: vec![KzgProof::empty()].try_into().unwrap(), + })) + } else { + Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: 1, + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot, + proposer_index: 1, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), }, - signature: Signature::empty(), - }, - column: vec![vec![0; E::bytes_per_cell()].try_into().unwrap()] + column: vec![vec![0; E::bytes_per_cell()].try_into().unwrap()] + .try_into() + .unwrap(), + kzg_commitments: vec![KzgCommitment::empty_for_testing()].try_into().unwrap(), + kzg_proofs: vec![KzgProof::empty()].try_into().unwrap(), + kzg_commitments_inclusion_proof: vec![ + Hash256::zero(); + E::kzg_commitments_inclusion_proof_depth() + ] .try_into() .unwrap(), - kzg_commitments: vec![KzgCommitment::empty_for_testing()].try_into().unwrap(), - kzg_proofs: vec![KzgProof::empty()].try_into().unwrap(), - kzg_commitments_inclusion_proof: vec![ - Hash256::zero(); - E::kzg_commitments_inclusion_proof_depth() - ] - .try_into() - .unwrap(), - }); + })) + }; let rpc_response = Response::DataColumnsByRoot(Some(data_column.clone())); @@ -1041,7 +1044,7 @@ fn test_tcp_columns_by_root_chunked_rpc() { loop { match sender.next_event().await { NetworkEvent::PeerConnectedOutgoing(peer_id) => { - tracing::info!("Sending RPC"); + info!("Sending RPC"); tokio::time::sleep(Duration::from_secs(1)).await; sender .send_request(peer_id, AppRequestId::Router, rpc_request.clone()) @@ -1055,7 +1058,7 @@ fn test_tcp_columns_by_root_chunked_rpc() { Response::DataColumnsByRoot(Some(sidecar)) => { assert_eq!(sidecar, data_column.clone()); messages_received += 1; - tracing::info!("Chunk received"); + info!("Chunk received"); } Response::DataColumnsByRoot(None) => { // should be exactly messages_to_send @@ -1079,30 +1082,28 @@ fn test_tcp_columns_by_root_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - tracing::info!("Receiver got request"); + } if request_type == rpc_request => { + // send the response + info!("Receiver got request"); - for _ in 0..messages_to_send { - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - tracing::info!("Sending message"); - } - // send the stream termination + for _ in 0..messages_to_send { receiver.send_response( peer_id, inbound_request_id, - Response::DataColumnsByRoot(None), + rpc_response.clone(), ); - tracing::info!("Send stream term"); + info!("Sending message"); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::DataColumnsByRoot(None), + ); + info!("Send stream term"); } e => { - tracing::info!(?e, "Got event"); + info!(?e, "Got event"); } // Ignore other events } } @@ -1120,7 +1121,17 @@ fn test_tcp_columns_by_root_chunked_rpc() { #[test] #[allow(clippy::single_match)] -fn test_tcp_columns_by_range_chunked_rpc() { +fn test_tcp_columns_by_root_chunked_rpc_fulu() { + test_tcp_columns_by_root_chunked_rpc_for_fork(ForkName::Fulu); +} + +#[test] +#[allow(clippy::single_match)] +fn test_tcp_columns_by_root_chunked_rpc_gloas() { + test_tcp_columns_by_root_chunked_rpc_for_fork(ForkName::Gloas); +} + +fn test_tcp_columns_by_range_chunked_rpc_for_fork(fork_name: ForkName) { // Set up the logging. let log_level = "debug"; let enable_logging = true; @@ -1129,14 +1140,17 @@ fn test_tcp_columns_by_range_chunked_rpc() { let messages_to_send = 32; let spec = Arc::new(spec_with_all_forks_enabled()); - let current_fork_name = ForkName::Fulu; + let slot = spec + .fork_epoch(fork_name) + .expect("fork must be scheduled") + .start_slot(E::slots_per_epoch()); let rt = Arc::new(Runtime::new().unwrap()); // get sender/receiver rt.block_on(async { let (mut sender, mut receiver) = common::build_node_pair( Arc::downgrade(&rt), - current_fork_name, + fork_name, spec.clone(), Protocol::Tcp, false, @@ -1146,36 +1160,48 @@ fn test_tcp_columns_by_range_chunked_rpc() { // DataColumnsByRange Request let rpc_request = RequestType::DataColumnsByRange(DataColumnsByRangeRequest { - start_slot: 320, + start_slot: slot.as_u64(), count: 32, columns: (0..E::number_of_columns() as u64).collect(), }); // DataColumnsByRange Response - let data_column = Arc::new(DataColumnSidecar { - index: 1, - signed_block_header: SignedBeaconBlockHeader { - message: BeaconBlockHeader { - slot: 320u64.into(), - proposer_index: 1, - parent_root: Hash256::zero(), - state_root: Hash256::zero(), - body_root: Hash256::zero(), + let data_column = if fork_name.gloas_enabled() { + Arc::new(DataColumnSidecar::Gloas(DataColumnSidecarGloas { + index: 1, + slot, + beacon_block_root: Hash256::zero(), + column: vec![vec![0; E::bytes_per_cell()].try_into().unwrap()] + .try_into() + .unwrap(), + kzg_proofs: vec![KzgProof::empty()].try_into().unwrap(), + })) + } else { + Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: 1, + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot, + proposer_index: 1, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), }, - signature: Signature::empty(), - }, - column: vec![vec![0; E::bytes_per_cell()].try_into().unwrap()] + column: vec![vec![0; E::bytes_per_cell()].try_into().unwrap()] + .try_into() + .unwrap(), + kzg_commitments: vec![KzgCommitment::empty_for_testing()].try_into().unwrap(), + kzg_proofs: vec![KzgProof::empty()].try_into().unwrap(), + kzg_commitments_inclusion_proof: vec![ + Hash256::zero(); + E::kzg_commitments_inclusion_proof_depth() + ] .try_into() .unwrap(), - kzg_commitments: vec![KzgCommitment::empty_for_testing()].try_into().unwrap(), - kzg_proofs: vec![KzgProof::empty()].try_into().unwrap(), - kzg_commitments_inclusion_proof: vec![ - Hash256::zero(); - E::kzg_commitments_inclusion_proof_depth() - ] - .try_into() - .unwrap(), - }); + })) + }; let rpc_response = Response::DataColumnsByRange(Some(data_column.clone())); @@ -1186,7 +1212,7 @@ fn test_tcp_columns_by_range_chunked_rpc() { loop { match sender.next_event().await { NetworkEvent::PeerConnectedOutgoing(peer_id) => { - tracing::info!("Sending RPC"); + info!("Sending RPC"); sender .send_request(peer_id, AppRequestId::Router, rpc_request.clone()) .unwrap(); @@ -1199,7 +1225,7 @@ fn test_tcp_columns_by_range_chunked_rpc() { Response::DataColumnsByRange(Some(sidecar)) => { assert_eq!(sidecar, data_column.clone()); messages_received += 1; - tracing::info!("Chunk received"); + info!("Chunk received"); } Response::DataColumnsByRange(None) => { // should be exactly messages_to_send @@ -1218,34 +1244,27 @@ fn test_tcp_columns_by_range_chunked_rpc() { // build the receiver future let receiver_future = async { loop { - match receiver.next_event().await { - NetworkEvent::RequestReceived { + if let NetworkEvent::RequestReceived { + peer_id, + inbound_request_id, + request_type, + } = receiver.next_event().await + && request_type == rpc_request + { + // send the response + info!("Receiver got request"); + + for _ in 0..messages_to_send { + receiver.send_response(peer_id, inbound_request_id, rpc_response.clone()); + info!("Sending message"); + } + // send the stream termination + receiver.send_response( peer_id, inbound_request_id, - request_type, - } => { - if request_type == rpc_request { - // send the response - tracing::info!("Receiver got request"); - - for _ in 0..messages_to_send { - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - tracing::info!("Sending message"); - } - // send the stream termination - receiver.send_response( - peer_id, - inbound_request_id, - Response::DataColumnsByRange(None), - ); - tracing::info!("Send stream term"); - } - } - _ => {} // Ignore other events + Response::DataColumnsByRange(None), + ); + info!("Send stream term"); } } } @@ -1260,6 +1279,18 @@ fn test_tcp_columns_by_range_chunked_rpc() { }) } +#[test] +#[allow(clippy::single_match)] +fn test_tcp_columns_by_range_chunked_rpc_fulu() { + test_tcp_columns_by_range_chunked_rpc_for_fork(ForkName::Fulu); +} + +#[test] +#[allow(clippy::single_match)] +fn test_tcp_columns_by_range_chunked_rpc_gloas() { + test_tcp_columns_by_range_chunked_rpc_for_fork(ForkName::Gloas); +} + // Tests a streamed, chunked BlocksByRoot RPC Message terminates when all expected reponses have been received #[test] fn test_tcp_blocks_by_root_chunked_rpc_terminates_correctly() { @@ -1375,12 +1406,10 @@ fn test_tcp_blocks_by_root_chunked_rpc_terminates_correctly() { request_type, }, _, - )) => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - message_info = Some((peer_id, inbound_request_id)); - } + )) if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + message_info = Some((peer_id, inbound_request_id)); } futures::future::Either::Right((_, _)) => {} // The timeout hit, send messages if required _ => continue, @@ -1739,3 +1768,157 @@ fn test_active_requests() { } }) } + +// Test that when a node receives an invalid BlocksByRange request exceeding the maximum count, +// it bans the sender. +#[test] +fn test_request_too_large_blocks_by_range() { + let spec = Arc::new(spec_with_all_forks_enabled()); + + test_request_too_large( + AppRequestId::Sync(SyncRequestId::BlocksByRange(BlocksByRangeRequestId { + id: 1, + parent_request_id: ComponentsByRangeRequestId { + id: 1, + requester: RangeRequestId::RangeSync { + chain_id: 1, + batch_id: Epoch::new(1), + }, + }, + })), + RequestType::BlocksByRange(OldBlocksByRangeRequest::new( + 0, + spec.max_request_blocks(ForkName::Base) as u64 + 1, // exceeds the max request defined in the spec. + 1, + )), + ); +} + +// Test that when a node receives an invalid BlobsByRange request exceeding the maximum count, +// it bans the sender. +#[test] +fn test_request_too_large_blobs_by_range() { + let spec = Arc::new(spec_with_all_forks_enabled()); + + let max_request_blobs_count = spec.max_request_blob_sidecars(ForkName::Base) as u64 + / spec.max_blobs_per_block_within_fork(ForkName::Base); + test_request_too_large( + AppRequestId::Sync(SyncRequestId::BlobsByRange(BlobsByRangeRequestId { + id: 1, + parent_request_id: ComponentsByRangeRequestId { + id: 1, + requester: RangeRequestId::RangeSync { + chain_id: 1, + batch_id: Epoch::new(1), + }, + }, + })), + RequestType::BlobsByRange(BlobsByRangeRequest { + start_slot: 0, + count: max_request_blobs_count + 1, // exceeds the max request defined in the spec. + }), + ); +} + +// Test that when a node receives an invalid DataColumnsByRange request exceeding the columns count, +// it bans the sender. +#[test] +fn test_request_too_large_data_columns_by_range() { + test_request_too_large( + AppRequestId::Sync(SyncRequestId::DataColumnsByRange( + DataColumnsByRangeRequestId { + id: 1, + parent_request_id: DataColumnsByRangeRequester::ComponentsByRange( + ComponentsByRangeRequestId { + id: 1, + requester: RangeRequestId::RangeSync { + chain_id: 1, + batch_id: Epoch::new(1), + }, + }, + ), + peer: PeerId::random(), + }, + )), + RequestType::DataColumnsByRange(DataColumnsByRangeRequest { + start_slot: 0, + count: 0, + // exceeds the max request defined in the spec. + columns: vec![0; E::number_of_columns() + 1], + }), + ); +} + +fn test_request_too_large(app_request_id: AppRequestId, request: RequestType<E>) { + // Set up the logging. + let log_level = "debug"; + let enable_logging = true; + let _subscriber = build_tracing_subscriber(log_level, enable_logging); + let rt = Arc::new(Runtime::new().unwrap()); + let spec = Arc::new(spec_with_all_forks_enabled()); + + rt.block_on(async { + let (mut sender, mut receiver) = common::build_node_pair( + Arc::downgrade(&rt), + ForkName::Base, + spec, + Protocol::Tcp, + false, + None, + ) + .await; + + // Build the sender future + let sender_future = async { + loop { + match sender.next_event().await { + NetworkEvent::PeerConnectedOutgoing(peer_id) => { + debug!(?request, %peer_id, "Sending RPC request"); + sender + .send_request(peer_id, app_request_id, request.clone()) + .unwrap(); + } + NetworkEvent::ResponseReceived { + app_request_id, + response, + .. + } => { + debug!(?app_request_id, ?response, "Received response"); + } + NetworkEvent::RPCFailed { error, .. } => { + // This variant should be unreachable, as the receiver doesn't respond with an error when a request exceeds the limit. + debug!(?error, "RPC failed"); + unreachable!(); + } + NetworkEvent::PeerDisconnected(peer_id) => { + // The receiver should disconnect as a result of the invalid request. + debug!(%peer_id, "Peer disconnected"); + // End the test. + return; + } + _ => {} + } + } + } + .instrument(info_span!("Sender")); + + // Build the receiver future + let receiver_future = async { + loop { + if let NetworkEvent::RequestReceived { .. } = receiver.next_event().await { + // This event should be unreachable, as the handler drops the invalid request. + unreachable!(); + } + } + } + .instrument(info_span!("Receiver")); + + tokio::select! { + _ = sender_future => {} + _ = receiver_future => {} + _ = sleep(Duration::from_secs(30)) => { + panic!("Future timed out"); + } + } + }); +} diff --git a/beacon_node/lighthouse_tracing/Cargo.toml b/beacon_node/lighthouse_tracing/Cargo.toml deleted file mode 100644 index cd71c20253..0000000000 --- a/beacon_node/lighthouse_tracing/Cargo.toml +++ /dev/null @@ -1,4 +0,0 @@ -[package] -name = "lighthouse_tracing" -version = "0.1.0" -edition = { workspace = true } diff --git a/beacon_node/lighthouse_tracing/src/lib.rs b/beacon_node/lighthouse_tracing/src/lib.rs deleted file mode 100644 index 56dccadaa9..0000000000 --- a/beacon_node/lighthouse_tracing/src/lib.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! This module contains root span identifiers for key code paths in the beacon node. -//! -//! TODO: These span identifiers will be used to implement selective tracing export (to be implemented), -//! where only the listed root spans and their descendants will be exported to the tracing backend. - -/// Root span names for block production and publishing -pub const SPAN_PRODUCE_BLOCK_V2: &str = "produce_block_v2"; -pub const SPAN_PRODUCE_BLOCK_V3: &str = "produce_block_v3"; -pub const SPAN_PUBLISH_BLOCK: &str = "publish_block"; - -/// Data Availability checker span identifiers -pub const SPAN_PENDING_COMPONENTS: &str = "pending_components"; - -/// Gossip methods root spans -pub const SPAN_PROCESS_GOSSIP_DATA_COLUMN: &str = "process_gossip_data_column"; -pub const SPAN_PROCESS_GOSSIP_BLOB: &str = "process_gossip_blob"; -pub const SPAN_PROCESS_GOSSIP_BLOCK: &str = "process_gossip_block"; - -/// Sync methods root spans -pub const SPAN_SYNCING_CHAIN: &str = "syncing_chain"; -pub const SPAN_OUTGOING_RANGE_REQUEST: &str = "outgoing_range_request"; -pub const SPAN_SINGLE_BLOCK_LOOKUP: &str = "single_block_lookup"; -pub const SPAN_OUTGOING_BLOCK_BY_ROOT_REQUEST: &str = "outgoing_block_by_root_request"; -pub const SPAN_OUTGOING_CUSTODY_REQUEST: &str = "outgoing_custody_request"; -pub const SPAN_PROCESS_RPC_BLOCK: &str = "process_rpc_block"; -pub const SPAN_PROCESS_RPC_BLOBS: &str = "process_rpc_blobs"; -pub const SPAN_PROCESS_RPC_CUSTODY_COLUMNS: &str = "process_rpc_custody_columns"; -pub const SPAN_PROCESS_CHAIN_SEGMENT: &str = "process_chain_segment"; -pub const SPAN_CUSTODY_BACKFILL_SYNC_BATCH_REQUEST: &str = "custody_backfill_sync_batch_request"; -pub const SPAN_PROCESS_CHAIN_SEGMENT_BACKFILL: &str = "process_chain_segment_backfill"; -pub const SPAN_CUSTODY_BACKFILL_SYNC_IMPORT_COLUMNS: &str = "custody_backfill_sync_import_columns"; - -/// Fork choice root spans -pub const SPAN_RECOMPUTE_HEAD: &str = "recompute_head_at_slot"; - -/// RPC methods root spans -pub const SPAN_HANDLE_BLOCKS_BY_RANGE_REQUEST: &str = "handle_blocks_by_range_request"; -pub const SPAN_HANDLE_BLOBS_BY_RANGE_REQUEST: &str = "handle_blobs_by_range_request"; -pub const SPAN_HANDLE_DATA_COLUMNS_BY_RANGE_REQUEST: &str = "handle_data_columns_by_range_request"; -pub const SPAN_HANDLE_BLOCKS_BY_ROOT_REQUEST: &str = "handle_blocks_by_root_request"; -pub const SPAN_HANDLE_BLOBS_BY_ROOT_REQUEST: &str = "handle_blobs_by_root_request"; -pub const SPAN_HANDLE_DATA_COLUMNS_BY_ROOT_REQUEST: &str = "handle_data_columns_by_root_request"; -pub const SPAN_HANDLE_LIGHT_CLIENT_UPDATES_BY_RANGE: &str = "handle_light_client_updates_by_range"; -pub const SPAN_HANDLE_LIGHT_CLIENT_BOOTSTRAP: &str = "handle_light_client_bootstrap"; -pub const SPAN_HANDLE_LIGHT_CLIENT_OPTIMISTIC_UPDATE: &str = - "handle_light_client_optimistic_update"; -pub const SPAN_HANDLE_LIGHT_CLIENT_FINALITY_UPDATE: &str = "handle_light_client_finality_update"; - -/// List of all root span names that are allowed to be exported to the tracing backend. -/// Only these spans and their descendants will be processed to reduce noise from -/// uninstrumented code paths. New root spans must be added to this list to be traced. -pub const LH_BN_ROOT_SPAN_NAMES: &[&str] = &[ - SPAN_PRODUCE_BLOCK_V2, - SPAN_PRODUCE_BLOCK_V3, - SPAN_PUBLISH_BLOCK, - SPAN_PENDING_COMPONENTS, - SPAN_PROCESS_GOSSIP_DATA_COLUMN, - SPAN_PROCESS_GOSSIP_BLOB, - SPAN_PROCESS_GOSSIP_BLOCK, - SPAN_SYNCING_CHAIN, - SPAN_OUTGOING_RANGE_REQUEST, - SPAN_SINGLE_BLOCK_LOOKUP, - SPAN_PROCESS_RPC_BLOCK, - SPAN_PROCESS_RPC_BLOBS, - SPAN_PROCESS_RPC_CUSTODY_COLUMNS, - SPAN_PROCESS_CHAIN_SEGMENT, - SPAN_PROCESS_CHAIN_SEGMENT_BACKFILL, - SPAN_HANDLE_BLOCKS_BY_RANGE_REQUEST, - SPAN_HANDLE_BLOBS_BY_RANGE_REQUEST, - SPAN_HANDLE_DATA_COLUMNS_BY_RANGE_REQUEST, - SPAN_HANDLE_BLOCKS_BY_ROOT_REQUEST, - SPAN_HANDLE_BLOBS_BY_ROOT_REQUEST, - SPAN_HANDLE_DATA_COLUMNS_BY_ROOT_REQUEST, - SPAN_HANDLE_LIGHT_CLIENT_UPDATES_BY_RANGE, - SPAN_HANDLE_LIGHT_CLIENT_BOOTSTRAP, - SPAN_HANDLE_LIGHT_CLIENT_OPTIMISTIC_UPDATE, - SPAN_HANDLE_LIGHT_CLIENT_FINALITY_UPDATE, - SPAN_CUSTODY_BACKFILL_SYNC_BATCH_REQUEST, - SPAN_CUSTODY_BACKFILL_SYNC_IMPORT_COLUMNS, -]; diff --git a/beacon_node/network/Cargo.toml b/beacon_node/network/Cargo.toml index bf26196576..68c77252ab 100644 --- a/beacon_node/network/Cargo.toml +++ b/beacon_node/network/Cargo.toml @@ -8,6 +8,7 @@ edition = { workspace = true } # NOTE: This can be run via cargo build --bin lighthouse --features network/disable-backfill disable-backfill = [] fork_from_env = ["beacon_chain/fork_from_env"] +fake_crypto = ["bls/fake_crypto", "kzg/fake_crypto"] portable = ["beacon_chain/portable"] test_logger = [] @@ -29,7 +30,6 @@ hex = { workspace = true } igd-next = { version = "0.16", features = ["aio_tokio"] } itertools = { workspace = true } lighthouse_network = { workspace = true } -lighthouse_tracing = { workspace = true } logging = { workspace = true } lru_cache = { workspace = true } metrics = { workspace = true } @@ -54,10 +54,11 @@ bls = { workspace = true } eth2 = { workspace = true } eth2_network_config = { workspace = true } genesis = { workspace = true } -gossipsub = { workspace = true } k256 = "0.13.4" kzg = { workspace = true } +libp2p = { workspace = true } matches = "0.1.8" +paste = { workspace = true } rand_08 = { package = "rand", version = "0.8.5" } rand_chacha = "0.9.0" rand_chacha_03 = { package = "rand_chacha", version = "0.3.1" } diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index cea06a28c8..b09dc95db4 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -143,6 +143,22 @@ pub static BEACON_PROCESSOR_GOSSIP_DATA_COLUMN_SIDECAR_VERIFIED_TOTAL: LazyLock< "Total number of gossip data column sidecar verified for propagation.", ) }); +pub static BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_VERIFIED_TOTAL: LazyLock< + Result<IntCounter>, +> = LazyLock::new(|| { + try_create_int_counter( + "beacon_processor_gossip_partial_data_column_verified_total", + "Total number of gossip partial data column sidecar verified for propagation.", + ) +}); +pub static BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_MISSING_HEADER_TOTAL: LazyLock< + Result<IntCounter>, +> = LazyLock::new(|| { + try_create_int_counter( + "beacon_processor_gossip_partial_data_column_missing_header_total", + "Total number of gossip partial data column sidecar received without a (cached) header.", + ) +}); // Gossip Exits. pub static BEACON_PROCESSOR_EXIT_VERIFIED_TOTAL: LazyLock<Result<IntCounter>> = LazyLock::new(|| { @@ -462,6 +478,13 @@ pub static SYNCING_CHAIN_BATCH_AWAITING_PROCESSING: LazyLock<Result<Histogram>> ]), ) }); +pub static SYNCING_CHAIN_BATCHES: LazyLock<Result<IntGaugeVec>> = LazyLock::new(|| { + try_create_int_gauge_vec( + "sync_batches", + "Number of batches in sync chains by sync type and state", + &["sync_type", "state"], + ) +}); pub static SYNC_SINGLE_BLOCK_LOOKUPS: LazyLock<Result<IntGauge>> = LazyLock::new(|| { try_create_int_gauge( "sync_single_block_lookups", @@ -507,6 +530,40 @@ pub static SYNC_UNKNOWN_NETWORK_REQUESTS: LazyLock<Result<IntCounterVec>> = Lazy &["type"], ) }); +pub static SYNC_RPC_REQUEST_SUCCESSES: LazyLock<Result<IntCounterVec>> = LazyLock::new(|| { + try_create_int_counter_vec( + "sync_rpc_requests_success_total", + "Total count of sync RPC requests successes", + &["protocol"], + ) +}); +pub static SYNC_RPC_REQUEST_ERRORS: LazyLock<Result<IntCounterVec>> = LazyLock::new(|| { + try_create_int_counter_vec( + "sync_rpc_requests_error_total", + "Total count of sync RPC requests errors", + &["protocol", "error"], + ) +}); +pub static SYNC_RPC_REQUEST_TIME: LazyLock<Result<HistogramVec>> = LazyLock::new(|| { + try_create_histogram_vec_with_buckets( + "sync_rpc_request_duration_sec", + "Time to complete a successful sync RPC requesst", + Ok(vec![ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 1.0, 2.0, + ]), + &["protocol"], + ) +}); + +/* + * Execution Payload Envelope Delay Metrics + */ +pub static ENVELOPE_DELAY_GOSSIP: LazyLock<Result<IntGauge>> = LazyLock::new(|| { + try_create_int_gauge( + "payload_envelope_delay_gossip", + "The first time we see this payload envelope from gossip as a delay from the start of the slot", + ) +}); /* * Block Delay Metrics @@ -560,6 +617,16 @@ pub static BEACON_DATA_COLUMN_GOSSIP_PROPAGATION_VERIFICATION_DELAY_TIME: LazyLo decimal_buckets(-3, -1), ) }); +pub static BEACON_PARTIAL_DATA_COLUMN_GOSSIP_PROPAGATION_VERIFICATION_DELAY_TIME: LazyLock< + Result<Histogram>, +> = LazyLock::new(|| { + try_create_histogram_with_buckets( + "beacon_partial_data_column_gossip_propagation_verification_delay_time", + "Duration between when the partial data column sidecar is received over gossip and when it is verified for propagation.", + // [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5] + decimal_buckets(-3, -1), + ) +}); pub static BEACON_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME: LazyLock<Result<Histogram>> = LazyLock::new(|| { try_create_histogram_with_buckets( @@ -574,6 +641,28 @@ pub static BEACON_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME: LazyLock<Result<Hist //decimal_buckets(-1,2) ) }); +pub static BEACON_PARTIAL_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME: LazyLock<Result<Histogram>> = + LazyLock::new(|| { + try_create_histogram_with_buckets( + "beacon_partial_data_column_gossip_slot_start_delay_time", + "Duration between when the partial data column sidecar is received over gossip and the start of the slot it belongs to.", + // Create a custom bucket list for greater granularity in block delay + Ok(vec![ + 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, + 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, + ]), // NOTE: Previous values, which we may want to switch back to. + // [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50] + //decimal_buckets(-1,2) + ) + }); +pub static BEACON_USEFUL_FULL_COLUMNS_RECEIVED_TOTAL: LazyLock<Result<IntCounterVec>> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_useful_full_columns_received_total", + "Number of useful full columns (any cell being useful) received", + &["column_index"], + ) + }); pub static BEACON_BLOB_DELAY_GOSSIP_VERIFICATION: LazyLock<Result<IntGauge>> = LazyLock::new( || { diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 83931f221e..06acb3f92e 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4,12 +4,17 @@ use crate::{ service::NetworkMessage, sync::SyncMessage, }; -use beacon_chain::blob_verification::{GossipBlobError, GossipVerifiedBlob}; use beacon_chain::block_verification_types::AsBlock; -use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; +use beacon_chain::data_column_verification::{ + GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, + GossipVerifiedPartialDataColumnHeader, KzgVerifiedPartialDataColumn, + PartialColumnVerificationResult, +}; use beacon_chain::inclusion_list_verification::{ GossipInclusionListError, GossipVerifiedInclusionList, }; +use beacon_chain::payload_bid_verification::PayloadBidError; +use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; use beacon_chain::store::Error; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainError, BeaconChainTypes, BlockError, ForkChoiceError, @@ -19,13 +24,22 @@ use beacon_chain::{ light_client_finality_update_verification::Error as LightClientFinalityUpdateError, light_client_optimistic_update_verification::Error as LightClientOptimisticUpdateError, observed_operations::ObservationOutcome, + payload_attestation_verification::{ + Error as PayloadAttestationError, VerifiedPayloadAttestationMessage, + }, sync_committee_verification::{self, Error as SyncCommitteeError}, validator_monitor::{get_block_delay_ms, get_slot_delay_ms}, }; +use beacon_chain::{ + blob_verification::{GossipBlobError, GossipVerifiedBlob}, + payload_envelope_verification::{ + EnvelopeError, gossip_verified_envelope::GossipVerifiedEnvelope, + }, +}; use beacon_processor::{Work, WorkEvent}; -use lighthouse_network::{Client, MessageAcceptance, MessageId, PeerAction, PeerId, ReportSource}; -use lighthouse_tracing::{ - SPAN_PROCESS_GOSSIP_BLOB, SPAN_PROCESS_GOSSIP_BLOCK, SPAN_PROCESS_GOSSIP_DATA_COLUMN, +use lighthouse_network::{ + Client, GossipTopic, MessageAcceptance, MessageId, PeerAction, PeerId, PubsubMessage, + ReportSource, }; use logging::crit; use operation_pool::ReceivedPreCapella; @@ -39,20 +53,22 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use store::hot_cold_store::HotColdDBError; use tracing::{Instrument, Span, debug, error, info, instrument, trace, warn}; use types::{ - Attestation, AttestationData, AttestationRef, AttesterSlashing, BlobSidecar, DataColumnSidecar, - DataColumnSubnetId, EthSpec, Hash256, IndexedAttestation, LightClientFinalityUpdate, - LightClientOptimisticUpdate, ProposerSlashing, SignedAggregateAndProof, SignedBeaconBlock, - SignedBlsToExecutionChange, SignedContributionAndProof, SignedInclusionList, - SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, SyncCommitteeMessage, SyncSubnetId, - beacon_block::BlockImportSource, + Attestation, AttestationData, AttestationRef, AttesterSlashing, BlobSidecar, ColumnIndex, + DataColumnSidecar, DataColumnSubnetId, EthSpec, Hash256, IndexedAttestation, + LightClientFinalityUpdate, LightClientOptimisticUpdate, PartialDataColumn, + PartialDataColumnHeader, PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, + SignedBeaconBlock, SignedBlsToExecutionChange, SignedContributionAndProof, + SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedInclusionList, + SignedProposerPreferences, SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, + SyncCommitteeMessage, SyncSubnetId, block::BlockImportSource, }; use beacon_processor::work_reprocessing_queue::QueuedColumnReconstruction; use beacon_processor::{ DuplicateCache, GossipAggregatePackage, GossipAttestationBatch, work_reprocessing_queue::{ - QueuedAggregate, QueuedGossipBlock, QueuedLightClientUpdate, QueuedUnaggregate, - ReprocessQueueMessage, + QueuedAggregate, QueuedGossipBlock, QueuedGossipEnvelope, QueuedLightClientUpdate, + QueuedUnaggregate, ReprocessQueueMessage, }, }; @@ -193,6 +209,19 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }) } + /// Send a message on `message_tx` that `peer_id` has sent an invalid partial message and should + /// be penalized. + pub(crate) fn propagate_partial_validation_failure( + &self, + propagation_source: PeerId, + gossip_topic: GossipTopic, + ) { + self.send_network_message(NetworkMessage::PartialValidationFailure { + propagation_source, + gossip_topic, + }) + } + /* Processing functions */ /// Process the unaggregated attestation received from the gossip network and: @@ -286,7 +315,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }) .collect::<Vec<_>>(); - for (result, package) in results.into_iter().zip(packages.into_iter()) { + for (result, package) in results.into_iter().zip(packages) { let result = match result { Ok((indexed_attestation, attestation)) => Ok(VerifiedUnaggregate { indexed_attestation, @@ -492,7 +521,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { .map(|result| result.map(|verified| verified.into_indexed_attestation())) .collect::<Vec<_>>(); - for (result, package) in results.into_iter().zip(packages.into_iter()) { + for (result, package) in results.into_iter().zip(packages) { let result = match result { Ok(indexed_attestation) => Ok(VerifiedAggregate { indexed_attestation, @@ -545,6 +574,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { aggregate, indexed_attestation, &self.chain.slot_clock, + &self.chain.spec, ); metrics::inc_counter( @@ -609,11 +639,11 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } #[instrument( - name = SPAN_PROCESS_GOSSIP_DATA_COLUMN, + name = "lh_process_gossip_data_column", parent = None, level = "debug", skip_all, - fields(slot = %column_sidecar.slot(), block_root = ?column_sidecar.block_root(), index = column_sidecar.index), + fields(slot = %column_sidecar.slot(), block_root = ?column_sidecar.block_root(), index = column_sidecar.index()), )] pub async fn process_gossip_data_column_sidecar( self: &Arc<Self>, @@ -625,7 +655,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { ) { let slot = column_sidecar.slot(); let block_root = column_sidecar.block_root(); - let index = column_sidecar.index; + let index = *column_sidecar.index(); let delay = get_slot_delay_ms(seen_duration, slot, &self.chain.slot_clock); // Log metrics to track delay from other nodes on the network. metrics::observe_duration( @@ -671,6 +701,15 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } Err(err) => { match err { + GossipDataColumnError::InvalidVariant => { + // TODO(gloas) we should probably penalize the peer here + debug!( + %slot, + %block_root, + %index, + "Invalid gossip data column variant." + ) + } GossipDataColumnError::PriorKnownUnpublished => { debug!( %slot, @@ -684,7 +723,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { MessageAcceptance::Accept, ); } - GossipDataColumnError::ParentUnknown { parent_root } => { + GossipDataColumnError::ParentUnknown { parent_root, .. } => { debug!( action = "requesting parent", %block_root, @@ -710,6 +749,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { | GossipDataColumnError::InvalidSubnetId { .. } | GossipDataColumnError::InvalidInclusionProof | GossipDataColumnError::InvalidKzgProof { .. } + | GossipDataColumnError::MismatchesCachedColumn | GossipDataColumnError::UnexpectedDataColumn | GossipDataColumnError::InvalidColumnIndex(_) | GossipDataColumnError::MaxBlobsPerBlockExceeded { .. } @@ -771,9 +811,264 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } } + #[instrument( + name = "lh_process_gossip_partial_data_column", + parent = None, + level = "debug", + skip_all, + fields(block_root = ?column.block_root, index = column.index), + )] + pub async fn process_gossip_partial_data_column_sidecar( + self: &Arc<Self>, + peer_id: PeerId, + column: Box<PartialDataColumn<T::EthSpec>>, + seen_duration: Duration, + topic: GossipTopic, + ) { + let block_root = column.block_root; + let index = column.index; + + let result = self + .chain + .verify_partial_data_column_sidecar_for_gossip(column, seen_duration); + + let header = match result { + PartialColumnVerificationResult::Ok { header, column } => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_VERIFIED_TOTAL, + ); + + let slot = header.as_header().slot(); + + debug!( + %slot, + %block_root, + %index, + "Successfully verified gossip partial data column sidecar" + ); + + // Log metrics to keep track of propagation delay times. + if let Some(duration) = UNIX_EPOCH + .elapsed() + .ok() + .and_then(|now| now.checked_sub(seen_duration)) + { + metrics::observe_duration( + &metrics::BEACON_PARTIAL_DATA_COLUMN_GOSSIP_PROPAGATION_VERIFICATION_DELAY_TIME, + duration, + ); + } + + self.process_gossip_verified_partial_data_column( + peer_id, + column, + header.clone(), + slot, + ) + .await; + Some(header) + } + PartialColumnVerificationResult::ErrWithValidHeader { header, err } => { + self.handle_partial_verification_error(peer_id, err, block_root, index, topic); + Some(header) + } + PartialColumnVerificationResult::Err(err) => { + self.handle_partial_verification_error(peer_id, err, block_root, index, topic); + None + } + }; + + if let Some(header) = header { + let slot = header.as_header().slot(); + let delay = get_slot_delay_ms(seen_duration, slot, &self.chain.slot_clock); + // Log metrics to track delay from other nodes on the network. + metrics::observe_duration( + &metrics::BEACON_PARTIAL_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME, + delay, + ); + + if !header.was_cached() { + debug!(block = %block_root, "Triggering getBlobs after receiving partial header"); + // We want to publish immediately when this finishes + let publish_blobs = true; + self.fetch_engine_blobs_and_publish(header.into_header(), block_root, publish_blobs) + .await + } + } + } + + fn handle_partial_verification_error( + self: &Arc<Self>, + peer_id: PeerId, + err: GossipPartialDataColumnError, + block_root: Hash256, + index: ColumnIndex, + topic: GossipTopic, + ) { + match err { + GossipPartialDataColumnError::GossipDataColumnError(err) => match err { + GossipDataColumnError::InvalidVariant => { + // TODO(gloas) we should probably penalize the peer here + debug!( + %block_root, + %index, + "Invalid gossip partial data column variant." + ) + } + GossipDataColumnError::PriorKnownUnpublished => { + debug!( + %block_root, + %index, + "Gossip partial data column already processed via the EL." + ); + } + GossipDataColumnError::ParentUnknown { parent_root, slot } => { + debug!( + action = "requesting parent", + %block_root, + %parent_root, + "Unknown parent hash for partial column" + ); + self.send_sync_message(SyncMessage::UnknownParentPartialDataColumn { + peer_id, + block_root, + parent_root, + slot, + }); + } + GossipDataColumnError::PubkeyCacheTimeout + | GossipDataColumnError::BeaconChainError(_) => { + crit!( + error = ?err, + "Internal error when verifying partial column sidecar" + ) + } + GossipDataColumnError::ProposalSignatureInvalid + | GossipDataColumnError::UnknownValidator(_) + | GossipDataColumnError::ProposerIndexMismatch { .. } + | GossipDataColumnError::IsNotLaterThanParent { .. } + | GossipDataColumnError::InvalidSubnetId { .. } + | GossipDataColumnError::InvalidInclusionProof + | GossipDataColumnError::InvalidKzgProof { .. } + | GossipDataColumnError::MismatchesCachedColumn + | GossipDataColumnError::UnexpectedDataColumn + | GossipDataColumnError::InvalidColumnIndex(_) + | GossipDataColumnError::MaxBlobsPerBlockExceeded { .. } + | GossipDataColumnError::InconsistentCommitmentsLength { .. } + | GossipDataColumnError::InconsistentProofsLength { .. } + | GossipDataColumnError::NotFinalizedDescendant { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify partial column for gossip. Rejecting the column sidecar" + ); + // Prevent recurring behaviour by penalizing the peer slightly. + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + self.propagate_partial_validation_failure(peer_id, topic); + } + GossipDataColumnError::PriorKnown { .. } => { + // Data column is available via either the EL or reconstruction. + // Do not penalise the peer. + // Gossip filter should filter any duplicates received after this. + debug!( + %block_root, + %index, + "Received already available column sidecar. Ignoring the partial column sidecar" + ) + } + GossipDataColumnError::FutureSlot { .. } + | GossipDataColumnError::PastFinalizedSlot { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify column sidecar for gossip. Ignoring the partial column sidecar" + ); + // Prevent recurring behaviour by penalizing the peer slightly. + self.gossip_penalize_peer( + peer_id, + PeerAction::HighToleranceError, + "gossip_partial_data_column_high", + ); + } + }, + GossipPartialDataColumnError::MissingHeader => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_MISSING_HEADER_TOTAL, + ); + warn!( + error = ?err, + %block_root, + %index, + "Received partial column while not having header stored" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::HighToleranceError, + "gossip_partial_data_column_high", + ); + } + GossipPartialDataColumnError::HeaderMismatches + | GossipPartialDataColumnError::HeaderIncorrectRoot { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify partial column header" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + } + GossipPartialDataColumnError::EmptyMessage + | GossipPartialDataColumnError::InconsistentPresentCount { .. } + | GossipPartialDataColumnError::InconsistentCommitmentsLength { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify partial column" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + } + GossipPartialDataColumnError::PartialColumnsDisabled => { + error!( + error = ?err, + %block_root, + %index, + "Received partial column while disabled" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + } + GossipPartialDataColumnError::InternalError(_) => { + error!( + error = ?err, + %block_root, + %index, + "Internal error while processing partial column" + ); + } + } + } + #[allow(clippy::too_many_arguments)] #[instrument( - name = SPAN_PROCESS_GOSSIP_BLOB, + name = "lh_process_gossip_blob", parent = None, level = "debug", skip_all, @@ -805,7 +1100,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { Ok(gossip_verified_blob) => { metrics::inc_counter(&metrics::BEACON_PROCESSOR_GOSSIP_BLOB_VERIFIED_TOTAL); - if delay >= self.chain.slot_clock.unagg_attestation_production_delay() { + if delay >= self.chain.spec.get_unaggregated_attestation_due() { metrics::inc_counter(&metrics::BEACON_BLOB_GOSSIP_ARRIVED_LATE_TOTAL); debug!( block_root = ?gossip_verified_blob.block_root(), @@ -1017,6 +1312,8 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } } + /// Process a gossip-verified full data column (not partial). + /// Partials are handled by process_gossip_verified_partial_data_column. async fn process_gossip_verified_data_column( self: &Arc<Self>, peer_id: PeerId, @@ -1029,6 +1326,30 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { let data_column_slot = verified_data_column.slot(); let data_column_index = verified_data_column.index(); + if let DataColumnSidecar::Fulu(col) = verified_data_column.as_data_column() + && self + .chain + .data_availability_checker + .partial_assembler() + .is_some_and(|a| !a.is_complete(block_root, verified_data_column.index())) + { + metrics::inc_counter_vec( + &metrics::BEACON_USEFUL_FULL_COLUMNS_RECEIVED_TOTAL, + &[&data_column_index.to_string()], + ); + + let mut column = col.to_partial(); + let header = column.sidecar.header.take(); + if let Some(header) = header { + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns: vec![Arc::new(column)], + header: Arc::new(header), + }); + } else { + crit!("Converting from full to partial yielded headerless partial") + }; + } + let result = self .chain .process_gossip_data_columns(vec![verified_data_column], || Ok(())) @@ -1057,44 +1378,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { "Processed data column, waiting for other components" ); - if self - .chain - .data_availability_checker - .custody_context() - .should_attempt_reconstruction( - slot.epoch(T::EthSpec::slots_per_epoch()), - &self.chain.spec, - ) - { - // Instead of triggering reconstruction immediately, schedule it to be run. If - // another column arrives, it either completes availability or pushes - // reconstruction back a bit. - let cloned_self = Arc::clone(self); - let block_root = *block_root; - - if self - .beacon_processor_send - .try_send(WorkEvent { - drop_during_sync: false, - work: Work::Reprocess( - ReprocessQueueMessage::DelayColumnReconstruction( - QueuedColumnReconstruction { - block_root, - slot: *slot, - process_fn: Box::pin(async move { - cloned_self - .attempt_data_column_reconstruction(block_root) - .await; - }), - }, - ), - ), - }) - .is_err() - { - warn!("Unable to send reconstruction to reprocessing"); - } - } + self.check_reconstruction_trigger(*slot, block_root).await; } }, Err(BlockError::DuplicateFullyImported(_)) => { @@ -1130,6 +1414,183 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } } + /// Process a gossip-verified partial data column by merging it in the assembler + async fn process_gossip_verified_partial_data_column( + self: &Arc<Self>, + _peer_id: PeerId, + verified_partial: KzgVerifiedPartialDataColumn<T::EthSpec>, + verified_header: GossipVerifiedPartialDataColumnHeader<T::EthSpec>, + slot: Slot, + ) { + let processing_start_time = Instant::now(); + let block_root = verified_partial.block_root(); + let data_column_index = verified_partial.index(); + + let result = self + .chain + .process_gossip_partial_data_column(verified_partial, verified_header.clone(), slot) + .await; + + // First, handle merge results (if any) + let result = match result { + Ok(Some((avail, merge_result))) => { + if !merge_result.full_columns.is_empty() { + debug!( + %block_root, + index = data_column_index, + "Partial data column completed to full column" + ); + + self.send_network_message(NetworkMessage::Publish { + messages: merge_result + .full_columns + .into_iter() + .map(|col| { + let subnet = DataColumnSubnetId::from_column_index( + col.index(), + &self.chain.spec, + ); + PubsubMessage::DataColumnSidecar(Box::new(( + subnet, + col.into_inner(), + ))) + }) + .collect(), + }); + } + + let only_send_completed_partials = + merge_result.local_blobs || self.chain.config.disable_get_blobs; + let columns = merge_result + .updated_partials + .into_iter() + .map(|partial| partial.into_inner()) + .filter(|partial| { + !only_send_completed_partials || partial.sidecar.is_complete() + }) + .collect::<Vec<_>>(); + + if !columns.is_empty() { + if only_send_completed_partials { + debug!( + block = %block_root, + "Not publishing incomplete partials before getBlobs" + ); + } + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns, + header: verified_header.into_header(), + }); + } + Ok(avail) + } + Ok(None) => { + // Column was not merged because it is not a custody column. + return; + } + Err(err) => Err(err), + }; + + register_process_result_metrics( + &result, + metrics::BlockSource::Gossip, + "partial_data_column", + ); + + match &result { + Ok(availability) => match availability { + AvailabilityProcessingStatus::Imported(block_root) => { + debug!( + %block_root, + "Data column from partial processed, imported fully available block" + ); + self.chain.recompute_head_at_current_slot().await; + + metrics::set_gauge( + &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, + processing_start_time.elapsed().as_millis() as i64, + ); + } + AvailabilityProcessingStatus::MissingComponents(slot, block_root) => { + trace!( + %slot, + %data_column_index, + %block_root, + "Processed data column from partial, waiting for other components" + ); + + self.check_reconstruction_trigger(*slot, block_root).await; + } + }, + Err(BlockError::DuplicateFullyImported(_)) => { + debug!( + ?block_root, + data_column_index, "Ignoring completed gossip column already imported" + ); + } + Err(err) => { + debug!( + outcome = ?err, + ?block_root, + block_slot = %slot, + data_column_index, + "Invalid completed gossip data column" + ); + // We can't really penalize here, as the error might be the fault of another peer + // contributing to the partial. + } + } + + // If a block is in the da_checker, sync maybe awaiting for an event when block is finally + // imported. A block can become imported both after processing a block or data column. If a + // importing a block results in `Imported`, notify. Do not notify of data column errors. + if matches!(result, Ok(AvailabilityProcessingStatus::Imported(_))) { + self.send_sync_message(SyncMessage::GossipBlockProcessResult { + block_root, + imported: true, + }); + } + } + + async fn check_reconstruction_trigger(self: &Arc<Self>, slot: Slot, block_root: &Hash256) { + if self + .chain + .data_availability_checker + .custody_context() + .should_attempt_reconstruction( + slot.epoch(T::EthSpec::slots_per_epoch()), + &self.chain.spec, + ) + { + // Instead of triggering reconstruction immediately, schedule it to be run. If + // another column arrives, it either completes availability or pushes + // reconstruction back a bit. + let cloned_self = Arc::clone(self); + let block_root = *block_root; + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::DelayColumnReconstruction( + QueuedColumnReconstruction { + block_root, + slot, + process_fn: Box::pin(async move { + cloned_self + .attempt_data_column_reconstruction(block_root) + .await; + }), + }, + )), + }) + .is_err() + { + warn!("Unable to send reconstruction to reprocessing"); + } + } + } + /// Process the beacon block received from the gossip network and: /// /// - If it passes gossip propagation criteria, tell the network thread to forward it. @@ -1139,7 +1600,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Raises a log if there are errors. #[allow(clippy::too_many_arguments)] #[instrument( - name = SPAN_PROCESS_GOSSIP_BLOCK, + name = "lh_process_gossip_block", parent = None, level = "debug", skip_all, @@ -1209,31 +1670,28 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { .verify_block_for_gossip(block.clone()) .await; - if verification_result.is_ok() { + let block_root = if let Ok(verified_block) = &verification_result { metrics::set_gauge( &metrics::BEACON_BLOCK_DELAY_GOSSIP, block_delay.as_millis() as i64, ); - } - - let block_root = if let Ok(verified_block) = &verification_result { + // Write the time the block was observed into delay cache only for gossip + // valid blocks. + self.chain.block_times_cache.write().set_time_observed( + verified_block.block_root, + block.slot(), + seen_duration, + Some(peer_id.to_string()), + Some(peer_client.to_string()), + ); verified_block.block_root } else { block.canonical_root() }; - // Write the time the block was observed into delay cache. - self.chain.block_times_cache.write().set_time_observed( - block_root, - block.slot(), - seen_duration, - Some(peer_id.to_string()), - Some(peer_client.to_string()), - ); - let verified_block = match verification_result { Ok(verified_block) => { - if block_delay >= self.chain.slot_clock.unagg_attestation_production_delay() { + if block_delay >= self.chain.spec.get_unaggregated_attestation_due() { metrics::inc_counter(&metrics::BEACON_BLOCK_DELAY_GOSSIP_ARRIVED_LATE_TOTAL); debug!( block_root = ?verified_block.block_root, @@ -1354,7 +1812,8 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { | Err(e @ BlockError::ParentExecutionPayloadInvalid { .. }) | Err(e @ BlockError::KnownInvalidExecutionPayload(_)) | Err(e @ BlockError::GenesisBlock) - | Err(e @ BlockError::InvalidBlobCount { .. }) => { + | Err(e @ BlockError::InvalidBlobCount { .. }) + | Err(e @ BlockError::BidParentRootMismatch { .. }) => { warn!(error = %e, "Could not verify block for gossip. Rejecting the block"); self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); self.gossip_penalize_peer( @@ -1494,9 +1953,11 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { let current_span = Span::current(); self.executor.spawn( async move { - self_clone - .fetch_engine_blobs_and_publish(block_clone, block_root, publish_blobs) - .await + if let Ok(header) = PartialDataColumnHeader::try_from(block_clone.as_ref()) { + self_clone + .fetch_engine_blobs_and_publish(Arc::new(header), block_root, publish_blobs) + .await + } } .instrument(current_span), "fetch_blobs_gossip", @@ -1910,6 +2371,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { seen_timestamp, sync_signature.sync_message(), &self.chain.slot_clock, + &self.chain.spec, ); metrics::inc_counter(&metrics::BEACON_PROCESSOR_SYNC_MESSAGE_VERIFIED_TOTAL); @@ -1972,6 +2434,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { sync_contribution.aggregate(), sync_contribution.participant_pubkeys(), &self.chain.slot_clock, + &self.chain.spec, ); metrics::inc_counter(&metrics::BEACON_PROCESSOR_SYNC_CONTRIBUTION_VERIFIED_TOTAL); @@ -2454,6 +2917,25 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { "attn_comm_index_non_zero", ); } + AttnError::CommitteeIndexInvalid => { + /* + * The committee index is invalid after Gloas. + * + * The peer has published an invalid consensus message. + */ + debug!( + %peer_id, + block = ?beacon_block_root, + ?attestation_type, + "Committee index invalid" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "attn_comm_index_invalid", + ); + } AttnError::UnknownHeadBlock { beacon_block_root } => { trace!( %peer_id, @@ -3191,6 +3673,23 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { self.propagate_if_timely(is_timely, message_id, peer_id) } + /// If a payload envelope is still valid with respect to the current time (i.e., its slot + /// matches the current slot), propagate it on gossip. Otherwise, ignore it. + fn propagate_envelope_if_timely( + &self, + envelope_slot: Slot, + message_id: MessageId, + peer_id: PeerId, + ) { + let is_timely = self + .chain + .slot_clock + .now() + .is_some_and(|current_slot| envelope_slot == current_slot); + + self.propagate_if_timely(is_timely, message_id, peer_id) + } + /// If a sync committee signature or sync committee contribution is still valid with respect to /// the current time (i.e., timely), propagate it on gossip. Otherwise, ignore it. fn propagate_sync_message_if_timely( @@ -3262,4 +3761,542 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { write_file(error_path, error.to_string().as_bytes()); } } + + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "lh_process_execution_payload_envelope", + parent = None, + level = "debug", + skip_all, + fields(beacon_block_root = tracing::field::Empty), + )] + pub async fn process_gossip_execution_payload_envelope( + self: Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + seen_timestamp: Duration, + ) { + if let Some(gossip_verified_envelope) = self + .process_gossip_unverified_execution_payload_envelope( + message_id, + peer_id, + envelope.clone(), + seen_timestamp, + ) + .await + { + let beacon_block_root = gossip_verified_envelope.signed_envelope.beacon_block_root(); + + Span::current().record("beacon_block_root", beacon_block_root.to_string()); + + // TODO(gloas) in process_gossip_block here we check_and_insert on the duplicate cache + // before calling gossip_verified_block. We need this to ensure we dont try to execute the + // payload multiple times. + + self.process_gossip_verified_execution_payload_envelope( + peer_id, + gossip_verified_envelope, + ) + .await; + } + } + + async fn process_gossip_unverified_execution_payload_envelope( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + envelope: Arc<SignedExecutionPayloadEnvelope<T::EthSpec>>, + seen_duration: Duration, + ) -> Option<GossipVerifiedEnvelope<T>> { + let envelope_delay = + get_slot_delay_ms(seen_duration, envelope.slot(), &self.chain.slot_clock); + + let verification_result = self + .chain + .clone() + .verify_envelope_for_gossip(envelope.clone()) + .await; + + let verified_envelope = match verification_result { + Ok(verified_envelope) => { + metrics::set_gauge( + &metrics::ENVELOPE_DELAY_GOSSIP, + envelope_delay.as_millis() as i64, + ); + + // Write the time the envelope was observed into the delay cache. + self.chain.envelope_times_cache.write().set_time_observed( + verified_envelope.signed_envelope.beacon_block_root(), + envelope.slot(), + seen_duration, + Some(peer_id.to_string()), + ); + + info!( + slot = %verified_envelope.signed_envelope.slot(), + root = ?verified_envelope.signed_envelope.beacon_block_root(), + "New envelope received" + ); + + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + + verified_envelope + } + Err(e) => { + match e { + EnvelopeError::ExecutionPayloadError(ref epe) if !epe.penalize_peer() => { + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } + + EnvelopeError::BadSignature + | EnvelopeError::BuilderIndexMismatch { .. } + | EnvelopeError::SlotMismatch { .. } + | EnvelopeError::BlockHashMismatch { .. } + | EnvelopeError::UnknownValidator { .. } + | EnvelopeError::IncorrectBlockProposer { .. } + | EnvelopeError::ExecutionPayloadError(_) + | EnvelopeError::EnvelopeProcessingError(_) + | EnvelopeError::BlockError(_) => { + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Reject, + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_low", + ); + } + + EnvelopeError::BlockRootUnknown { block_root } => { + let envelope_slot = envelope.slot(); + + debug!( + ?block_root, + %envelope_slot, + "Envelope references unknown block, deferring to reprocess queue" + ); + + self.propagate_validation_result( + message_id.clone(), + peer_id, + MessageAcceptance::Ignore, + ); + + let inner_self = self.clone(); + let chain = self.chain.clone(); + let process_fn = Box::pin(async move { + match chain.verify_envelope_for_gossip(envelope).await { + Ok(verified_envelope) => { + let envelope_slot = verified_envelope.signed_envelope.slot(); + inner_self.propagate_envelope_if_timely( + envelope_slot, + message_id, + peer_id, + ); + inner_self + .process_gossip_verified_execution_payload_envelope( + peer_id, + verified_envelope, + ) + .await; + } + Err(e) => { + debug!( + error = ?e, + "Deferred envelope failed verification" + ); + } + } + }); + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess( + ReprocessQueueMessage::UnknownBlockForEnvelope( + QueuedGossipEnvelope { + beacon_block_slot: envelope_slot, + beacon_block_root: block_root, + process_fn, + }, + ), + ), + }) + .is_err() + { + error!( + %envelope_slot, + ?block_root, + "Failed to defer envelope import" + ); + } + } + + EnvelopeError::PriorToFinalization { .. } + | EnvelopeError::OptimisticSyncNotSupported { .. } + | EnvelopeError::BeaconChainError(_) + | EnvelopeError::BeaconStateError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::InternalError(_) => { + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } + } + return None; + } + }; + + let envelope_slot = verified_envelope.signed_envelope.slot(); + let beacon_block_root = verified_envelope.signed_envelope.beacon_block_root(); + match self.chain.slot() { + // We only need to do a simple check about the envelope slot vs the current slot because + // `verify_envelope_for_gossip` already ensures that the envelope slot is within tolerance + // for envelope imports. + Ok(current_slot) if envelope_slot > current_slot => { + warn!( + ?envelope_slot, + ?beacon_block_root, + msg = "if this happens consistently, check system clock", + "envelope arrived early" + ); + + // TODO(gloas) update metrics to note how early the envelope arrived + + let inner_self = self.clone(); + let _process_fn = Box::pin(async move { + inner_self + .process_gossip_verified_execution_payload_envelope( + peer_id, + verified_envelope, + ) + .await; + }); + + // TODO(gloas) send to reprocess queue + None + } + Ok(_) => Some(verified_envelope), + Err(e) => { + error!( + error = ?e, + %envelope_slot, + ?beacon_block_root, + location = "envelope gossip", + "Failed to defer envelope import" + ); + None + } + } + } + + async fn process_gossip_verified_execution_payload_envelope( + self: Arc<Self>, + peer_id: PeerId, + verified_envelope: GossipVerifiedEnvelope<T>, + ) { + let _processing_start_time = Instant::now(); + let beacon_block_root = verified_envelope.signed_envelope.beacon_block_root(); + + #[allow(clippy::result_large_err)] + let result = self + .chain + .process_execution_payload_envelope( + beacon_block_root, + verified_envelope, + NotifyExecutionLayer::Yes, + BlockImportSource::Gossip, + || Ok(()), + ) + .await; + + // TODO(gloas) metrics + // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); + + match &result { + Ok(AvailabilityProcessingStatus::Imported(_)) + | Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => { + // Nothing to do + } + Err(e) => match e { + EnvelopeError::ExecutionPayloadError(epe) if !epe.penalize_peer() => {} + EnvelopeError::BadSignature + | EnvelopeError::BuilderIndexMismatch { .. } + | EnvelopeError::SlotMismatch { .. } + | EnvelopeError::BlockHashMismatch { .. } + | EnvelopeError::UnknownValidator { .. } + | EnvelopeError::IncorrectBlockProposer { .. } + | EnvelopeError::ExecutionPayloadError(_) => { + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_processing_low", + ); + } + + EnvelopeError::EnvelopeProcessingError(_) + | EnvelopeError::BlockError(_) + | EnvelopeError::BlockRootUnknown { .. } => { + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_processing_error", + ); + } + + EnvelopeError::PriorToFinalization { .. } + | EnvelopeError::OptimisticSyncNotSupported { .. } + | EnvelopeError::BeaconChainError(_) + | EnvelopeError::BeaconStateError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::InternalError(_) => {} + }, + } + } + + #[instrument( + name = "lh_process_execution_payload_bid", + parent = None, + level = "debug", + skip_all, + fields(parent_block_hash = ?bid.message.parent_block_hash, parent_block_root = ?bid.message.parent_block_root), + )] + pub fn process_gossip_execution_payload_bid( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + bid: Arc<SignedExecutionPayloadBid<T::EthSpec>>, + ) { + let verification_result = self.chain.verify_payload_bid_for_gossip(bid.clone()); + + match verification_result { + Ok(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + } + Err( + PayloadBidError::BadSignature + | PayloadBidError::InvalidBuilder { .. } + | PayloadBidError::InvalidFeeRecipient + | PayloadBidError::InvalidGasLimit + | PayloadBidError::ExecutionPaymentNonZero { .. } + | PayloadBidError::InvalidBlobKzgCommitments { .. }, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "invalid_gossip_payload_bid", + ); + } + Err( + PayloadBidError::NoProposerPreferences { .. } + | PayloadBidError::BuilderAlreadySeen { .. } + | PayloadBidError::BidValueBelowCached { .. } + | PayloadBidError::ParentBlockRootUnknown { .. } + | PayloadBidError::ParentBlockRootNotCanonical { .. } + | PayloadBidError::BuilderCantCoverBid { .. } + | PayloadBidError::BeaconStateError(_) + | PayloadBidError::InternalError(_) + | PayloadBidError::InvalidBidSlot { .. } + | PayloadBidError::UnableToReadSlot, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + } + } + + #[instrument( + name = "lh_process_proposer_preferences", + parent = None, + level = "debug", + skip_all, + fields(validator_index = ?proposer_preferences.message.validator_index, proposal_slot = ?proposer_preferences.message.proposal_slot), + )] + pub fn process_gossip_proposer_preferences( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + proposer_preferences: Arc<SignedProposerPreferences>, + ) { + let verification_result = self + .chain + .verify_proposer_preferences_for_gossip(proposer_preferences); + + match verification_result { + Ok(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + } + Err( + ProposerPreferencesError::AlreadySeen { .. } + | ProposerPreferencesError::InvalidProposalEpoch { .. } + | ProposerPreferencesError::ProposalSlotAlreadyPassed { .. } + | ProposerPreferencesError::BeaconChainError(_) + | ProposerPreferencesError::BeaconStateError(_) + | ProposerPreferencesError::UnableToReadSlot, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + Err( + ProposerPreferencesError::InvalidProposalSlot { .. } + | ProposerPreferencesError::BadSignature, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "invalid_gossip_proposer_preferences", + ); + } + } + } + + #[instrument( + level = "trace", + skip_all, + fields( + peer_id = %peer_id, + slot = %payload_attestation_message.data.slot, + validator_index = payload_attestation_message.validator_index, + ) + )] + pub fn process_gossip_payload_attestation( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + payload_attestation_message: Box<PayloadAttestationMessage>, + ) { + let message_slot = payload_attestation_message.data.slot; + let result = self + .chain + .verify_payload_attestation_message_for_gossip(*payload_attestation_message); + + self.process_gossip_payload_attestation_result(result, message_id, peer_id, message_slot); + } + + fn process_gossip_payload_attestation_result( + self: &Arc<Self>, + result: Result<VerifiedPayloadAttestationMessage<T>, PayloadAttestationError>, + message_id: MessageId, + peer_id: PeerId, + message_slot: Slot, + ) { + match result { + Ok(verified) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + + if let Err(e) = self.chain.apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) { + match e { + BeaconChainError::ForkChoiceError( + ForkChoiceError::InvalidPayloadAttestation(e), + ) => { + debug!( + reason = ?e, + %peer_id, + "Payload attestation invalid for fork choice" + ) + } + e => error!( + reason = ?e, + %peer_id, + "Error applying payload attestation to fork choice" + ), + } + } + + if let Err(e) = self.chain.add_payload_attestation_to_pool(&verified) { + warn!( + reason = ?e, + %peer_id, + "Failed to add payload attestation to pool" + ); + } + } + Err(error) => { + self.handle_payload_attestation_verification_failure( + peer_id, + message_id, + error, + message_slot, + ); + } + } + } + + fn handle_payload_attestation_verification_failure( + &self, + peer_id: PeerId, + message_id: MessageId, + error: PayloadAttestationError, + message_slot: Slot, + ) { + match &error { + PayloadAttestationError::FutureSlot { .. } => { + self.gossip_penalize_peer( + peer_id, + PeerAction::HighToleranceError, + "payload_attn_future_slot", + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + PayloadAttestationError::PastSlot { .. } + | PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. } => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + PayloadAttestationError::UnknownHeadBlock { .. } => { + debug!( + %peer_id, + %message_slot, + "Payload attestation references unknown block" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + PayloadAttestationError::NotInPTC { .. } => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "payload_attn_not_in_ptc", + ); + } + PayloadAttestationError::UnknownValidatorIndex(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "payload_attn_unknown_validator", + ); + } + PayloadAttestationError::InvalidSignature => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "payload_attn_invalid_sig", + ); + } + PayloadAttestationError::BeaconChainError(_) + | PayloadAttestationError::BeaconStateError(_) => { + debug!( + %peer_id, + %message_slot, + ?error, + "Internal error verifying payload attestation" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + } + } } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 127ff10a7b..3c6f60b83c 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -1,7 +1,8 @@ use crate::sync::manager::BlockProcessType; use crate::{service::NetworkMessage, sync::manager::SyncMessage}; use beacon_chain::blob_verification::{GossipBlobError, observe_gossip_blob}; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::data_column_verification::{GossipDataColumnError, observe_gossip_data_column}; use beacon_chain::fetch_blobs::{ EngineGetBlobsOutput, FetchEngineBlobError, fetch_and_process_engine_blobs, @@ -14,11 +15,12 @@ use beacon_processor::{ use lighthouse_network::rpc::InboundRequestId; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, DataColumnsByRootRequest, - LightClientUpdatesByRangeRequest, + LightClientUpdatesByRangeRequest, PayloadEnvelopesByRangeRequest, + PayloadEnvelopesByRootRequest, }; use lighthouse_network::service::api_types::CustodyBackfillBatchId; use lighthouse_network::{ - Client, MessageId, NetworkGlobals, PeerId, PubsubMessage, + Client, GossipTopic, MessageId, NetworkGlobals, PeerId, PubsubMessage, rpc::{BlocksByRangeRequest, BlocksByRootRequest, LightClientBootstrapRequest, StatusMessage}, }; use rand::prelude::SliceRandom; @@ -31,7 +33,7 @@ use tracing::{debug, error, instrument, trace, warn}; use types::*; pub use sync_methods::ChainSegmentProcessId; -use types::blob_sidecar::FixedBlobSidecarList; +use types::data::FixedBlobSidecarList; pub type Error<T> = TrySendError<BeaconWorkEvent<T>>; @@ -249,6 +251,32 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }) } + /// Create a new `Work` event for some partial data column sidecar. + pub fn send_gossip_partial_data_column_sidecar( + self: &Arc<Self>, + peer_id: PeerId, + column_sidecar: Box<PartialDataColumn<T::EthSpec>>, + seen_timestamp: Duration, + topic: GossipTopic, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = async move { + processor + .process_gossip_partial_data_column_sidecar( + peer_id, + column_sidecar, + seen_timestamp, + topic, + ) + .await + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::GossipPartialDataColumnSidecar(Box::pin(process_fn)), + }) + } + /// Create a new `Work` event for some sync committee signature. pub fn send_gossip_sync_signature( self: &Arc<Self>, @@ -447,16 +475,108 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }) } + /// Create a new `Work` event for some execution payload envelope. + pub fn send_gossip_execution_payload( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + execution_payload: Box<SignedExecutionPayloadEnvelope<T::EthSpec>>, + seen_timestamp: Duration, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = async move { + processor + .process_gossip_execution_payload_envelope( + message_id, + peer_id, + Arc::new(*execution_payload), + seen_timestamp, + ) + .await + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::GossipExecutionPayload(Box::pin(process_fn)), + }) + } + + /// Create a new `Work` event for some execution payload bid + pub fn send_gossip_execution_payload_bid( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + execution_payload_bid: Box<SignedExecutionPayloadBid<T::EthSpec>>, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = move || { + processor.process_gossip_execution_payload_bid( + message_id, + peer_id, + Arc::new(*execution_payload_bid), + ) + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: true, + work: Work::GossipExecutionPayloadBid(Box::new(process_fn)), + }) + } + + /// Create a new `Work` event for some payload attestation + pub fn send_gossip_payload_attestation( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + payload_attestation_message: Box<PayloadAttestationMessage>, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = move || { + processor.process_gossip_payload_attestation( + message_id, + peer_id, + payload_attestation_message, + ) + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: true, + work: Work::GossipPayloadAttestation(Box::new(process_fn)), + }) + } + + /// Create a new `Work` event for some proposer preferences + pub fn send_gossip_proposer_preferences( + self: &Arc<Self>, + message_id: MessageId, + peer_id: PeerId, + proposer_preferences: Box<SignedProposerPreferences>, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = move || { + processor.process_gossip_proposer_preferences( + message_id, + peer_id, + Arc::new(*proposer_preferences), + ) + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: true, + work: Work::GossipProposerPreferences(Box::new(process_fn)), + }) + } + /// Create a new `Work` event for some block, where the result from computation (if any) is /// sent to the other side of `result_tx`. - pub fn send_rpc_beacon_block( + pub fn send_lookup_beacon_block( self: &Arc<Self>, block_root: Hash256, - block: RpcBlock<T::EthSpec>, + block: LookupBlock<T::EthSpec>, seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error<T::EthSpec>> { - let process_fn = self.clone().generate_rpc_beacon_block_process_fn( + let process_fn = self.clone().generate_lookup_beacon_block_process_fn( block_root, block, seen_timestamp, @@ -464,7 +584,10 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { ); self.try_send(BeaconWorkEvent { drop_during_sync: false, - work: Work::RpcBlock { process_fn }, + work: Work::RpcBlock { + process_fn, + beacon_block_root: block_root, + }, }) } @@ -539,7 +662,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { pub fn send_chain_segment( self: &Arc<Self>, process_id: ChainSegmentProcessId, - blocks: Vec<RpcBlock<T::EthSpec>>, + blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) -> Result<(), Error<T::EthSpec>> { debug!(blocks = blocks.len(), id = ?process_id, "Batch sending for process"); let processor = self.clone(); @@ -547,11 +670,14 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { // Back-sync batches are dispatched with a different `Work` variant so // they can be rate-limited. let work = match process_id { - ChainSegmentProcessId::RangeBatchId(_, _) => { + ChainSegmentProcessId::RangeBatchId(chain_id, epoch) => { let process_fn = async move { processor.process_chain_segment(process_id, blocks).await; }; - Work::ChainSegment(Box::pin(process_fn)) + Work::ChainSegment { + process_fn: Box::pin(process_fn), + process_id: (chain_id, epoch.as_u64()), + } } ChainSegmentProcessId::BackSyncBatchId(_) => { let process_fn = @@ -621,6 +747,46 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }) } + /// Create a new work event to process `PayloadEnvelopesByRootRequest`s from the RPC network. + pub fn send_payload_envelopes_by_roots_request( + self: &Arc<Self>, + peer_id: PeerId, + inbound_request_id: InboundRequestId, // Use ResponseId here + request: PayloadEnvelopesByRootRequest, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = async move { + processor + .handle_payload_envelopes_by_root_request(peer_id, inbound_request_id, request) + .await; + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::PayloadEnvelopesByRootRequest(Box::pin(process_fn)), + }) + } + + /// Create a new work event to process `PayloadEnvelopesByRangeRequest`s from the RPC network. + pub fn send_payload_envelopes_by_range_request( + self: &Arc<Self>, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: PayloadEnvelopesByRangeRequest, + ) -> Result<(), Error<T::EthSpec>> { + let processor = self.clone(); + let process_fn = async move { + processor + .handle_payload_envelopes_by_range_request(peer_id, inbound_request_id, request) + .await; + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::PayloadEnvelopesByRangeRequest(Box::pin(process_fn)), + }) + } + /// Create a new work event to process `BlobsByRangeRequest`s from the RPC network. pub fn send_blobs_by_range_request( self: &Arc<Self>, @@ -778,14 +944,14 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { pub async fn fetch_engine_blobs_and_publish( self: &Arc<Self>, - block: Arc<SignedBeaconBlock<T::EthSpec, FullPayload<T::EthSpec>>>, + header: Arc<PartialDataColumnHeader<T::EthSpec>>, block_root: Hash256, publish_blobs: bool, ) { if self.chain.config.disable_get_blobs { return; } - let epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); + let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); let custody_columns = self.chain.sampling_columns_for_epoch(epoch); let self_cloned = self.clone(); let publish_fn = move |blobs_or_data_column| { @@ -810,7 +976,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { match fetch_and_process_engine_blobs( self.chain.clone(), block_root, - block.clone(), + header.clone(), custody_columns, publish_fn, ) @@ -854,6 +1020,23 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { ); } } + + // Publish partial columns without eager send + if let Some(assembler) = self.chain.data_availability_checker.partial_assembler() { + let columns = assembler.get_partials_and_mark_as_local_fetched(block_root, &header); + if !columns.is_empty() { + debug!(block = %block_root, "Publishing all partials after getBlobs"); + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns: columns + .into_iter() + .map(|partial| partial.into_inner()) + .collect(), + header, + }); + } else { + debug!(block = %block_root, "No partials to publish after getBlobs"); + } + } } /// Attempts to reconstruct all data columns if the conditions checked in @@ -1004,7 +1187,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { .into_iter() .map(|d| { let subnet = - DataColumnSubnetId::from_column_index(d.index, &chain.spec); + DataColumnSubnetId::from_column_index(*d.index(), &chain.spec); PubsubMessage::DataColumnSidecar(Box::new((subnet, d))) }) .collect(), diff --git a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs index ac24b648e0..8b31b67acb 100644 --- a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs @@ -3,27 +3,22 @@ use crate::network_beacon_processor::{FUTURE_SLOT_TOLERANCE, NetworkBeaconProces use crate::service::NetworkMessage; use crate::status::ToStatusMessage; use crate::sync::SyncMessage; +use beacon_chain::payload_envelope_streamer::EnvelopeRequestSource; use beacon_chain::{BeaconChainError, BeaconChainTypes, BlockProcessStatus, WhenSlotSkipped}; use itertools::{Itertools, process_results}; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, DataColumnsByRootRequest, + PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::rpc::*; use lighthouse_network::{PeerId, ReportSource, Response, SyncInfo}; -use lighthouse_tracing::{ - SPAN_HANDLE_BLOBS_BY_RANGE_REQUEST, SPAN_HANDLE_BLOBS_BY_ROOT_REQUEST, - SPAN_HANDLE_BLOCKS_BY_RANGE_REQUEST, SPAN_HANDLE_BLOCKS_BY_ROOT_REQUEST, - SPAN_HANDLE_DATA_COLUMNS_BY_RANGE_REQUEST, SPAN_HANDLE_DATA_COLUMNS_BY_ROOT_REQUEST, - SPAN_HANDLE_LIGHT_CLIENT_BOOTSTRAP, SPAN_HANDLE_LIGHT_CLIENT_FINALITY_UPDATE, - SPAN_HANDLE_LIGHT_CLIENT_OPTIMISTIC_UPDATE, SPAN_HANDLE_LIGHT_CLIENT_UPDATES_BY_RANGE, -}; use methods::LightClientUpdatesByRangeRequest; use slot_clock::SlotClock; use std::collections::{HashMap, HashSet, hash_map::Entry}; use std::sync::Arc; use tokio_stream::StreamExt; -use tracing::{Span, debug, error, field, instrument, warn}; -use types::blob_sidecar::BlobIdentifier; +use tracing::{Span, debug, error, field, instrument, trace, warn}; +use types::data::BlobIdentifier; use types::{ColumnIndex, Epoch, EthSpec, Hash256, Slot}; impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { @@ -163,7 +158,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `BlocksByRoot` request from the peer. #[instrument( - name = SPAN_HANDLE_BLOCKS_BY_ROOT_REQUEST, + name = "lh_handle_blocks_by_root_request", parent = None, level = "debug", skip_all, @@ -261,9 +256,107 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { Ok(()) } + /// Handle a `ExecutionPayloadEnvelopesByRoot` request from the peer. + #[instrument( + name = "lh_handle_payload_envelopes_by_root_request", + parent = None, + level = "debug", + skip_all, + fields(peer_id = %peer_id, client = tracing::field::Empty) + )] + pub async fn handle_payload_envelopes_by_root_request( + self: Arc<Self>, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: PayloadEnvelopesByRootRequest, + ) { + let client = self.network_globals.client(&peer_id); + Span::current().record("client", field::display(client.kind)); + + self.terminate_response_stream( + peer_id, + inbound_request_id, + self.clone() + .handle_payload_envelopes_by_root_request_inner( + peer_id, + inbound_request_id, + request, + ) + .await, + Response::PayloadEnvelopesByRoot, + ); + } + + /// Handle a `ExecutionPayloadEnvelopesByRoot` request from the peer. + async fn handle_payload_envelopes_by_root_request_inner( + self: Arc<Self>, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: PayloadEnvelopesByRootRequest, + ) -> Result<(), (RpcErrorResponse, &'static str)> { + let log_results = |peer_id, requested_envelopes, send_envelope_count| { + debug!( + %peer_id, + requested = requested_envelopes, + returned = %send_envelope_count, + "ExecutionPayloadEnvelopes outgoing response processed" + ); + }; + + let requested_envelopes = request.beacon_block_roots.len(); + let mut envelope_stream = self.chain.get_payload_envelopes( + request.beacon_block_roots.to_vec(), + EnvelopeRequestSource::ByRoot, + ); + // Fetching payload envelopes is async because it may have to hit the execution layer for payloads. + let mut send_envelope_count = 0; + while let Some((root, result)) = envelope_stream.next().await { + match result.as_ref() { + Ok(Some(envelope)) => { + self.send_response( + peer_id, + inbound_request_id, + Response::PayloadEnvelopesByRoot(Some(envelope.clone())), + ); + send_envelope_count += 1; + } + Ok(None) => { + debug!( + %peer_id, + request_root = ?root, + "Peer requested unknown payload envelope" + ); + } + Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => { + debug!( + block_root = ?root, + reason = "execution layer not synced", + "Failed to fetch execution payload for payload envelopes by root request" + ); + log_results(peer_id, requested_envelopes, send_envelope_count); + return Err(( + RpcErrorResponse::ResourceUnavailable, + "Execution layer not synced", + )); + } + Err(e) => { + debug!( + ?peer_id, + request_root = ?root, + error = ?e, + "Error fetching payload envelope for peer" + ); + } + } + } + log_results(peer_id, requested_envelopes, send_envelope_count); + + Ok(()) + } + /// Handle a `BlobsByRoot` request from the peer. #[instrument( - name = SPAN_HANDLE_BLOBS_BY_ROOT_REQUEST, + name = "lh_handle_blobs_by_root_request", parent = None, level = "debug", skip_all, @@ -392,7 +485,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `DataColumnsByRoot` request from the peer. #[instrument( - name = SPAN_HANDLE_DATA_COLUMNS_BY_ROOT_REQUEST, + name = "lh_handle_data_columns_by_root_request", parent = None, level = "debug", skip_all, @@ -485,7 +578,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } #[instrument( - name = SPAN_HANDLE_LIGHT_CLIENT_UPDATES_BY_RANGE, + name = "lh_handle_light_client_updates_by_range", parent = None, level = "debug", skip_all, @@ -586,7 +679,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `LightClientBootstrap` request from the peer. #[instrument( - name = SPAN_HANDLE_LIGHT_CLIENT_BOOTSTRAP, + name = "lh_handle_light_client_bootstrap", parent = None, level = "debug", skip_all, @@ -626,7 +719,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `LightClientOptimisticUpdate` request from the peer. #[instrument( - name = SPAN_HANDLE_LIGHT_CLIENT_OPTIMISTIC_UPDATE, + name = "lh_handle_light_client_optimistic_update", parent = None, level = "debug", skip_all, @@ -660,7 +753,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `LightClientFinalityUpdate` request from the peer. #[instrument( - name = SPAN_HANDLE_LIGHT_CLIENT_FINALITY_UPDATE, + name = "lh_handle_light_client_finality_update", parent = None, level = "debug", skip_all, @@ -694,7 +787,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `BlocksByRange` request from the peer. #[instrument( - name = SPAN_HANDLE_BLOCKS_BY_RANGE_REQUEST, + name = "lh_handle_blocks_by_range_request", parent = None, level = "debug", skip_all, @@ -753,7 +846,10 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { ) .ok_or((RpcErrorResponse::ServerError, "shutting down"))? .await - .map_err(|_| (RpcErrorResponse::ServerError, "tokio join"))??; + .map_err(|_| (RpcErrorResponse::ServerError, "tokio join"))?? + .iter() + .map(|(root, _)| *root) + .collect::<Vec<_>>(); let current_slot = self .chain @@ -866,7 +962,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { req_start_slot: u64, req_count: u64, req_type: &str, - ) -> Result<Vec<Hash256>, (RpcErrorResponse, &'static str)> { + ) -> Result<Vec<(Hash256, Slot)>, (RpcErrorResponse, &'static str)> { let start_time = std::time::Instant::now(); let finalized_slot = self .chain @@ -876,7 +972,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { .epoch .start_slot(T::EthSpec::slots_per_epoch()); - let (block_roots, source) = if req_start_slot >= finalized_slot.as_u64() { + let (block_roots_and_slots, source) = if req_start_slot >= finalized_slot.as_u64() { // If the entire requested range is after finalization, use fork_choice ( self.chain @@ -920,14 +1016,14 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { req_type, start_slot = %req_start_slot, req_count, - roots_count = block_roots.len(), + roots_count = block_roots_and_slots.len(), source, elapsed = ?elapsed, %finalized_slot, "Range request block roots retrieved" ); - Ok(block_roots) + Ok(block_roots_and_slots) } /// Get block roots for a `BlocksByRangeRequest` from the store using roots iterator. @@ -935,7 +1031,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { &self, start_slot: u64, count: u64, - ) -> Result<Vec<Hash256>, (RpcErrorResponse, &'static str)> { + ) -> Result<Vec<(Hash256, Slot)>, (RpcErrorResponse, &'static str)> { let forwards_block_root_iter = match self.chain.forwards_iter_block_roots(Slot::from(start_slot)) { Ok(iter) => iter, @@ -983,14 +1079,196 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { // remove all skip slots i.e. duplicated roots Ok(block_roots .into_iter() - .map(|(root, _)| root) - .unique() + .unique_by(|(root, _)| *root) .collect::<Vec<_>>()) } + /// Handle a `ExecutionPayloadEnvelopesByRange` request from the peer. + #[instrument( + name = "lh_handle_payload_envelopes_by_range_request", + parent = None, + level = "debug", + skip_all, + fields(peer_id = %peer_id, client = tracing::field::Empty) + )] + pub async fn handle_payload_envelopes_by_range_request( + self: Arc<Self>, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + req: PayloadEnvelopesByRangeRequest, + ) { + let client = self.network_globals.client(&peer_id); + Span::current().record("client", field::display(client.kind)); + + self.terminate_response_stream( + peer_id, + inbound_request_id, + self.clone() + .handle_payload_envelopes_by_range_request_inner(peer_id, inbound_request_id, req) + .await, + Response::PayloadEnvelopesByRange, + ); + } + + /// Handle a `ExecutionPayloadEnvelopesByRange` request from the peer. + async fn handle_payload_envelopes_by_range_request_inner( + self: Arc<Self>, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + req: PayloadEnvelopesByRangeRequest, + ) -> Result<(), (RpcErrorResponse, &'static str)> { + let req_start_slot = req.start_slot; + let req_count = req.count; + + debug!( + %peer_id, + count = req_count, + start_slot = %req_start_slot, + "Received ExecutionPayloadEnvelopesByRange Request" + ); + + let request_start_slot = Slot::from(req_start_slot); + let fork_name = self + .chain + .spec + .fork_name_at_slot::<T::EthSpec>(request_start_slot); + + if !fork_name.gloas_enabled() { + return Err(( + RpcErrorResponse::InvalidRequest, + "Requested envelopes for pre-gloas slots", + )); + } + + // Spawn a blocking handle since get_block_roots_for_slot_range takes a sync lock on the + // fork-choice. + let network_beacon_processor = self.clone(); + let block_roots = self + .executor + .spawn_blocking_handle( + move || { + network_beacon_processor.get_block_roots_for_slot_range( + req_start_slot, + req_count, + "ExecutionPayloadEnvelopesByRange", + ) + }, + "get_block_roots_for_slot_range", + ) + .ok_or((RpcErrorResponse::ServerError, "shutting down"))? + .await + .map_err(|_| (RpcErrorResponse::ServerError, "tokio join"))?? + .iter() + .map(|(root, _)| *root) + .collect::<Vec<_>>(); + + let current_slot = self + .chain + .slot() + .unwrap_or_else(|_| self.chain.slot_clock.genesis_slot()); + + let log_results = |peer_id, payloads_sent| { + if payloads_sent < (req_count as usize) { + debug!( + %peer_id, + msg = "Failed to return all requested payload envelopes", + start_slot = %req_start_slot, + %current_slot, + requested = req_count, + returned = payloads_sent, + "ExecutionPayloadEnvelopesByRange outgoing response processed" + ); + } else { + debug!( + %peer_id, + start_slot = %req_start_slot, + %current_slot, + requested = req_count, + returned = payloads_sent, + "ExecutionPayloadEnvelopesByRange outgoing response processed" + ); + } + }; + + let mut envelope_stream = self + .chain + .get_payload_envelopes(block_roots, EnvelopeRequestSource::ByRange); + + // Fetching payload envelopes is async because it may have to hit the execution layer for payloads. + let mut envelopes_sent = 0; + while let Some((root, result)) = envelope_stream.next().await { + match result.as_ref() { + Ok(Some(envelope)) => { + // Due to skip slots, blocks could be out of the range, we ensure they + // are in the range before sending + if envelope.slot() >= req_start_slot + && envelope.slot() < req_start_slot.saturating_add(req.count) + { + envelopes_sent += 1; + self.send_network_message(NetworkMessage::SendResponse { + peer_id, + inbound_request_id, + response: Response::PayloadEnvelopesByRange(Some(envelope.clone())), + }); + } + } + Ok(None) => { + trace!( + request = ?req, + %peer_id, + request_root = ?root, + "No envelope for block root" + ); + } + Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => { + debug!( + block_root = ?root, + reason = "execution layer not synced", + "Failed to fetch execution payload for envelope by range request" + ); + log_results(peer_id, envelopes_sent); + // send the stream terminator + return Err(( + RpcErrorResponse::ResourceUnavailable, + "Execution layer not synced", + )); + } + Err(e) => { + if matches!( + e, + BeaconChainError::ExecutionLayerErrorPayloadReconstruction(_block_hash, boxed_error) + if matches!(**boxed_error, execution_layer::Error::EngineError(_)) + ) { + warn!( + info = "this may occur occasionally when the EE is busy", + block_root = ?root, + error = ?e, + "Error rebuilding payload for peer" + ); + } else { + error!( + block_root = ?root, + error = ?e, + "Error fetching payload envelope for peer" + ); + } + log_results(peer_id, envelopes_sent); + // send the stream terminator + return Err(( + RpcErrorResponse::ServerError, + "Failed fetching payload envelopes", + )); + } + } + } + + log_results(peer_id, envelopes_sent); + Ok(()) + } + /// Handle a `BlobsByRange` request from the peer. #[instrument( - name = SPAN_HANDLE_BLOBS_BY_RANGE_REQUEST, + name = "lh_handle_blobs_by_range_request", parent = None, skip_all, level = "debug", @@ -1092,7 +1370,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }; } - let block_roots = + let block_roots_and_slots = self.get_block_roots_for_slot_range(req.start_slot, effective_count, "BlobsByRange")?; let current_slot = self @@ -1113,7 +1391,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { let mut blobs_sent = 0; - for root in block_roots { + for (root, _) in block_roots_and_slots { match self.chain.get_blobs(&root) { Ok(blob_sidecar_list) => { for blob_sidecar in blob_sidecar_list.iter() { @@ -1155,7 +1433,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Handle a `DataColumnsByRange` request from the peer. #[instrument( - name = SPAN_HANDLE_DATA_COLUMNS_BY_RANGE_REQUEST, + name = "lh_handle_data_columns_by_range_request", parent = None, skip_all, level = "debug", @@ -1252,7 +1530,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { }; } - let block_roots = + let block_roots_and_slots = self.get_block_roots_for_slot_range(req.start_slot, req.count, "DataColumnsByRange")?; let mut data_columns_sent = 0; @@ -1269,9 +1547,10 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { .filter(|c| available_columns.contains(c)) .collect::<Vec<_>>(); - for root in block_roots { + for (root, slot) in block_roots_and_slots { + let fork_name = self.chain.spec.fork_name_at_slot::<T::EthSpec>(slot); for index in &indices_to_retrieve { - match self.chain.get_data_column(&root, index) { + match self.chain.get_data_column(&root, index, fork_name) { Ok(Some(data_column_sidecar)) => { // Due to skip slots, data columns could be out of the range, we ensure they // are in the range before sending diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index e49ae134fe..8f89b66948 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -6,9 +6,9 @@ use crate::sync::{ ChainId, manager::{BlockProcessType, SyncMessage}, }; -use beacon_chain::block_verification_types::{AsBlock, RpcBlock}; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::block_verification_types::{AsBlock, RangeSyncBlock}; use beacon_chain::data_availability_checker::AvailabilityCheckError; -use beacon_chain::data_availability_checker::MaybeAvailableBlock; use beacon_chain::historical_data_columns::HistoricalDataColumnError; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainTypes, BlockError, ChainSegmentResult, @@ -21,18 +21,13 @@ use beacon_processor::{ use beacon_processor::{Work, WorkEvent}; use lighthouse_network::PeerAction; use lighthouse_network::service::api_types::CustodyBackfillBatchId; -use lighthouse_tracing::{ - SPAN_CUSTODY_BACKFILL_SYNC_IMPORT_COLUMNS, SPAN_PROCESS_CHAIN_SEGMENT, - SPAN_PROCESS_CHAIN_SEGMENT_BACKFILL, SPAN_PROCESS_RPC_BLOBS, SPAN_PROCESS_RPC_BLOCK, - SPAN_PROCESS_RPC_CUSTODY_COLUMNS, -}; use logging::crit; use std::sync::Arc; use std::time::Duration; use store::KzgCommitment; use tracing::{debug, debug_span, error, info, instrument, warn}; -use types::beacon_block_body::format_kzg_commitments; -use types::blob_sidecar::FixedBlobSidecarList; +use types::data::FixedBlobSidecarList; +use types::kzg_ext::format_kzg_commitments; use types::{BlockImportSource, DataColumnSidecarList, Epoch, Hash256}; /// Id associated to a batch processing request, either a sync batch or a parent lookup. @@ -57,16 +52,16 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// /// This separate function was required to prevent a cycle during compiler /// type checking. - pub fn generate_rpc_beacon_block_process_fn( + pub fn generate_lookup_beacon_block_process_fn( self: Arc<Self>, block_root: Hash256, - block: RpcBlock<T::EthSpec>, + block: LookupBlock<T::EthSpec>, seen_timestamp: Duration, process_type: BlockProcessType, ) -> AsyncFn { let process_fn = async move { let duplicate_cache = self.duplicate_cache.clone(); - self.process_rpc_block( + self.process_lookup_block( block_root, block, seen_timestamp, @@ -79,15 +74,15 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } /// Returns the `process_fn` and `ignore_fn` required when requeuing an RPC block. - pub fn generate_rpc_beacon_block_fns( + pub fn generate_lookup_beacon_block_fns( self: Arc<Self>, block_root: Hash256, - block: RpcBlock<T::EthSpec>, + block: LookupBlock<T::EthSpec>, seen_timestamp: Duration, process_type: BlockProcessType, ) -> (AsyncFn, BlockingFn) { // An async closure which will import the block. - let process_fn = self.clone().generate_rpc_beacon_block_process_fn( + let process_fn = self.clone().generate_lookup_beacon_block_process_fn( block_root, block, seen_timestamp, @@ -107,16 +102,16 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Attempt to process a block received from a direct RPC request. #[allow(clippy::too_many_arguments)] #[instrument( - name = SPAN_PROCESS_RPC_BLOCK, + name = "lh_process_rpc_block", parent = None, level = "debug", skip_all, fields(?block_root), )] - pub async fn process_rpc_block( + pub async fn process_lookup_block( self: Arc<NetworkBeaconProcessor<T>>, block_root: Hash256, - block: RpcBlock<T::EthSpec>, + block: LookupBlock<T::EthSpec>, seen_timestamp: Duration, process_type: BlockProcessType, duplicate_cache: DuplicateCache, @@ -124,14 +119,14 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { // Check if the block is already being imported through another source let Some(handle) = duplicate_cache.check_and_insert(block_root) else { debug!( - action = "sending rpc block to reprocessing queue", + action = "sending lookup block to reprocessing queue", %block_root, ?process_type, "Gossip block is being processed" ); // Send message to work reprocess queue to retry the block - let (process_fn, ignore_fn) = self.clone().generate_rpc_beacon_block_fns( + let (process_fn, ignore_fn) = self.clone().generate_lookup_beacon_block_fns( block_root, block, seen_timestamp, @@ -166,7 +161,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { slot = %block.slot(), commitments_formatted, ?process_type, - "Processing RPC block" + "Processing Lookup block" ); let signed_beacon_block = block.block_cloned(); @@ -223,9 +218,15 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { // Block is valid, we can now attempt fetching blobs from EL using version hashes // derived from kzg commitments from the block, without having to wait for all blobs // to be sent from the peers if we already have them. - let publish_blobs = false; - self.fetch_engine_blobs_and_publish(signed_beacon_block, block_root, publish_blobs) - .await + if let Ok(header) = signed_beacon_block.as_ref().try_into() { + let publish_blobs = false; + self.fetch_engine_blobs_and_publish( + Arc::new(header), + block_root, + publish_blobs, + ) + .await; + } } _ => {} } @@ -261,7 +262,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Attempt to process a list of blobs received from a direct RPC request. #[instrument( - name = SPAN_PROCESS_RPC_BLOBS, + name = "lh_process_rpc_blobs", parent = None, level = "debug", skip_all, @@ -303,7 +304,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { && current_slot == slot { // Note: this metric is useful to gauge how long it takes to receive blobs requested - // over rpc. Since we always send the request for block components at `slot_clock.single_lookup_delay()` + // over rpc. Since we always send the request for block components at `get_unaggregated_attestation_due() / 2` // we can use that as a baseline to measure against. let delay = get_slot_delay_ms(seen_timestamp, slot, &self.chain.slot_clock); @@ -349,7 +350,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { } #[instrument( - name = SPAN_PROCESS_RPC_CUSTODY_COLUMNS, + name = "lh_process_rpc_custody_columns", parent = None, level = "debug", skip_all, @@ -374,7 +375,10 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { metrics::observe_duration(&metrics::BEACON_BLOB_RPC_SLOT_START_DELAY_TIME, delay); } - let mut indices = custody_columns.iter().map(|d| d.index).collect::<Vec<_>>(); + let mut indices = custody_columns + .iter() + .map(|d| *d.index()) + .collect::<Vec<_>>(); indices.sort_unstable(); debug!( ?indices, @@ -429,7 +433,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { expected_cgc: u64, ) { let _guard = debug_span!( - SPAN_CUSTODY_BACKFILL_SYNC_IMPORT_COLUMNS, + "lh_custody_backfill_sync_import_columns", epoch = %batch_id.epoch, columns_received_count = downloaded_columns.len() ) @@ -524,7 +528,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Attempt to import the chain segment (`blocks`) to the beacon chain, informing the sync /// thread if more blocks are needed to process it. #[instrument( - name = SPAN_PROCESS_CHAIN_SEGMENT, + name = "lh_process_chain_segment", parent = None, level = "debug", skip_all, @@ -533,7 +537,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { pub async fn process_chain_segment( &self, process_id: ChainSegmentProcessId, - downloaded_blocks: Vec<RpcBlock<T::EthSpec>>, + downloaded_blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) { let ChainSegmentProcessId::RangeBatchId(chain_id, epoch) = process_id else { // This is a request from range sync, this should _never_ happen @@ -605,7 +609,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { /// Attempt to import the chain segment (`blocks`) to the beacon chain, informing the sync /// thread if more blocks are needed to process it. #[instrument( - name = SPAN_PROCESS_CHAIN_SEGMENT_BACKFILL, + name = "lh_process_chain_segment_backfill", parent = None, level = "debug", skip_all, @@ -614,7 +618,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { pub fn process_chain_segment_backfill( &self, process_id: ChainSegmentProcessId, - downloaded_blocks: Vec<RpcBlock<T::EthSpec>>, + downloaded_blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) { let ChainSegmentProcessId::BackSyncBatchId(epoch) = process_id else { // this a request from RangeSync, this should _never_ happen @@ -685,7 +689,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { #[instrument(skip_all)] async fn process_blocks<'a>( &self, - downloaded_blocks: impl Iterator<Item = &'a RpcBlock<T::EthSpec>>, + downloaded_blocks: impl Iterator<Item = &'a RangeSyncBlock<T::EthSpec>>, notify_execution_layer: NotifyExecutionLayer, ) -> (usize, Result<(), ChainSegmentFailed>) { let blocks: Vec<_> = downloaded_blocks.cloned().collect(); @@ -719,21 +723,20 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> { #[instrument(skip_all)] fn process_backfill_blocks( &self, - downloaded_blocks: Vec<RpcBlock<T::EthSpec>>, + downloaded_blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) -> (usize, Result<(), ChainSegmentFailed>) { let total_blocks = downloaded_blocks.len(); - let available_blocks = match self + let available_blocks = downloaded_blocks + .into_iter() + .map(|block| block.into_available_block()) + .collect::<Vec<_>>(); + + match self .chain .data_availability_checker - .verify_kzg_for_rpc_blocks(downloaded_blocks) + .batch_verify_kzg_for_available_blocks(&available_blocks) { - Ok(blocks) => blocks - .into_iter() - .filter_map(|maybe_available| match maybe_available { - MaybeAvailableBlock::Available(block) => Some(block), - MaybeAvailableBlock::AvailabilityPending { .. } => None, - }) - .collect::<Vec<_>>(), + Ok(()) => {} Err(e) => match e { AvailabilityCheckError::StoreError(_) => { return ( diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index ed04fe7bb9..c4e7f8f8d1 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -8,9 +8,9 @@ use crate::{ service::NetworkMessage, sync::{SyncMessage, manager::BlockProcessType}, }; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::custody_context::NodeCustodyType; -use beacon_chain::data_column_verification::validate_data_column_sidecar_for_gossip; +use beacon_chain::data_column_verification::validate_data_column_sidecar_for_gossip_fulu; use beacon_chain::kzg_utils::blobs_to_data_column_sidecars; use beacon_chain::observed_data_sidecars::DoNotObserve; use beacon_chain::test_utils::{ @@ -19,11 +19,13 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{BeaconChain, WhenSlotSkipped}; use beacon_processor::{work_reprocessing_queue::*, *}; -use gossipsub::MessageAcceptance; +use bls::Signature; use itertools::Itertools; +use libp2p::gossipsub::MessageAcceptance; use lighthouse_network::rpc::InboundRequestId; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, MetaDataV3, + PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::{ Client, MessageId, NetworkConfig, NetworkGlobals, PeerId, Response, @@ -39,12 +41,15 @@ use std::iter::Iterator; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; -use types::blob_sidecar::{BlobIdentifier, FixedBlobSidecarList}; use types::{ - AttesterSlashing, BlobSidecar, BlobSidecarList, ChainSpec, DataColumnSidecarList, - DataColumnSubnetId, Epoch, EthSpec, Hash256, MainnetEthSpec, ProposerSlashing, - SignedAggregateAndProof, SignedBeaconBlock, SignedVoluntaryExit, SingleAttestation, Slot, - SubnetId, + AttesterSlashing, BlobSidecar, ChainSpec, DataColumnSidecarList, DataColumnSubnetId, Epoch, + EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, Hash256, + MainnetEthSpec, ProposerSlashing, SignedAggregateAndProof, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, +}; +use types::{ + BlobSidecarList, + data::{BlobIdentifier, FixedBlobSidecarList}, }; type E = MainnetEthSpec; @@ -118,6 +123,39 @@ impl TestRig { .await } + pub async fn new_with_skip_slots(chain_length: u64, skip_slots: &HashSet<u64>) -> Self { + let mut spec = test_spec::<E>(); + spec.shard_committee_period = 2; + let spec = Arc::new(spec); + let beacon_processor_config = BeaconProcessorConfig::default(); + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.clone()) + .deterministic_keypairs(VALIDATOR_COUNT) + .fresh_ephemeral_store() + .mock_execution_layer() + .node_custody_type(NodeCustodyType::Fullnode) + .chain_config(<_>::default()) + .build(); + + harness.advance_slot(); + + for slot in 1..=chain_length { + if !skip_slots.contains(&slot) { + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + } + + harness.advance_slot(); + } + + Self::from_harness(harness, beacon_processor_config, spec).await + } + pub async fn new_parametric( chain_length: u64, beacon_processor_config: BeaconProcessorConfig, @@ -148,6 +186,14 @@ impl TestRig { harness.advance_slot(); } + Self::from_harness(harness, beacon_processor_config, spec).await + } + + async fn from_harness( + harness: BeaconChainHarness<T>, + beacon_processor_config: BeaconProcessorConfig, + spec: Arc<ChainSpec>, + ) -> Self { let head = harness.chain.head_snapshot(); assert_eq!( @@ -309,7 +355,7 @@ impl TestRig { ) .unwrap() .into_iter() - .filter(|c| sampling_indices.contains(&c.index)) + .filter(|c| sampling_indices.contains(c.index())) .collect::<Vec<_>>(); (None, Some(custody_columns)) @@ -386,7 +432,7 @@ impl TestRig { .send_gossip_data_column_sidecar( junk_message_id(), junk_peer_id(), - DataColumnSubnetId::from_column_index(data_column.index, &self.chain.spec), + DataColumnSubnetId::from_column_index(*data_column.index(), &self.chain.spec), data_column.clone(), Duration::from_secs(0), ) @@ -394,24 +440,24 @@ impl TestRig { } } - pub fn enqueue_rpc_block(&self) { + pub fn enqueue_lookup_block(&self) { let block_root = self.next_block.canonical_root(); self.network_beacon_processor - .send_rpc_beacon_block( + .send_lookup_beacon_block( block_root, - RpcBlock::new_without_blobs(Some(block_root), self.next_block.clone()), + LookupBlock::new(self.next_block.clone()), std::time::Duration::default(), BlockProcessType::SingleBlock { id: 0 }, ) .unwrap(); } - pub fn enqueue_single_lookup_rpc_block(&self) { + pub fn enqueue_single_lookup_block(&self) { let block_root = self.next_block.canonical_root(); self.network_beacon_processor - .send_rpc_beacon_block( + .send_lookup_beacon_block( block_root, - RpcBlock::new_without_blobs(Some(block_root), self.next_block.clone()), + LookupBlock::new(self.next_block.clone()), std::time::Duration::default(), BlockProcessType::SingleBlock { id: 1 }, ) @@ -479,6 +525,29 @@ impl TestRig { .unwrap(); } + pub fn enqueue_payload_envelopes_by_range_request(&self, start_slot: u64, count: u64) { + self.network_beacon_processor + .send_payload_envelopes_by_range_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + PayloadEnvelopesByRangeRequest { start_slot, count }, + ) + .unwrap(); + } + + pub fn enqueue_payload_envelopes_by_root_request( + &self, + beacon_block_roots: RuntimeVariableList<Hash256>, + ) { + self.network_beacon_processor + .send_payload_envelopes_by_roots_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + PayloadEnvelopesByRootRequest { beacon_block_roots }, + ) + .unwrap(); + } + pub fn enqueue_backfill_batch(&self, epoch: Epoch) { self.network_beacon_processor .send_chain_segment( @@ -926,20 +995,20 @@ async fn data_column_reconstruction_at_deadline() { .set_current_time(slot_start + Duration::from_millis(reconstruction_deadline_millis)); let min_columns_for_reconstruction = E::number_of_columns() / 2; + + // Enqueue all columns first - at deadline, reconstruction races with gossip drain for i in 0..min_columns_for_reconstruction { rig.enqueue_gossip_data_columns(i); - rig.assert_event_journal_completes(&[WorkType::GossipDataColumnSidecar]) - .await; } - // Since we're at the reconstruction deadline, reconstruction should be triggered immediately - rig.assert_event_journal_with_timeout( - &[WorkType::ColumnReconstruction.into()], - Duration::from_millis(50), - false, - false, - ) - .await; + // Expect all gossip events + reconstruction + let mut expected_events: Vec<WorkType> = (0..min_columns_for_reconstruction) + .map(|_| WorkType::GossipDataColumnSidecar) + .collect(); + expected_events.push(WorkType::ColumnReconstruction); + + rig.assert_event_journal_contains_ordered(&expected_events) + .await; } // Test the column reconstruction is delayed for columns that arrive for a previous slot. @@ -1115,8 +1184,8 @@ async fn accept_processed_gossip_data_columns_without_import() { .into_iter() .map(|data_column| { let subnet_id = - DataColumnSubnetId::from_column_index(data_column.index, &rig.chain.spec); - validate_data_column_sidecar_for_gossip::<_, DoNotObserve>( + DataColumnSubnetId::from_column_index(*data_column.index(), &rig.chain.spec); + validate_data_column_sidecar_for_gossip_fulu::<_, DoNotObserve>( data_column, subnet_id, &rig.chain, @@ -1250,7 +1319,7 @@ async fn attestation_to_unknown_block_processed(import_method: BlockImportMethod } } BlockImportMethod::Rpc => { - rig.enqueue_rpc_block(); + rig.enqueue_lookup_block(); events.push(WorkType::RpcBlock); if num_blobs > 0 { rig.enqueue_single_lookup_rpc_blobs(); @@ -1336,7 +1405,7 @@ async fn aggregate_attestation_to_unknown_block(import_method: BlockImportMethod } } BlockImportMethod::Rpc => { - rig.enqueue_rpc_block(); + rig.enqueue_lookup_block(); events.push(WorkType::RpcBlock); if num_blobs > 0 { rig.enqueue_single_lookup_rpc_blobs(); @@ -1530,7 +1599,7 @@ async fn test_rpc_block_reprocessing() { let next_block_root = rig.next_block.canonical_root(); // Insert the next block into the duplicate cache manually let handle = rig.duplicate_cache.check_and_insert(next_block_root); - rig.enqueue_single_lookup_rpc_block(); + rig.enqueue_single_lookup_block(); rig.assert_event_journal_completes(&[WorkType::RpcBlock]) .await; @@ -1948,7 +2017,7 @@ async fn test_data_columns_by_range_request_only_returns_requested_columns() { } = next { if let Some(column) = data_column { - received_columns.push(column.index); + received_columns.push(*column.index()); } else { break; } @@ -1972,3 +2041,308 @@ async fn test_data_columns_by_range_request_only_returns_requested_columns() { "Should have received at least some data columns" ); } + +/// Test that DataColumnsByRange does not return duplicate data columns for skip slots. +/// +/// When skip slots occur, `forwards_iter_block_roots` returns the same block root for +/// consecutive slots. The deduplication in `get_block_roots_from_store` must use +/// `unique_by` on the root (not the full `(root, slot)` tuple) to avoid serving +/// duplicate data columns for the same block. +#[tokio::test] +async fn test_data_columns_by_range_no_duplicates_with_skip_slots() { + if test_spec::<E>().fulu_fork_epoch.is_none() { + return; + }; + + // Build a chain of 128 slots (4 epochs) with skip slots at positions 5 and 6. + // After 4 epochs, finalized_epoch=2 (finalized_slot=64). Requesting slots 0-9 + // satisfies req_start_slot + req_count <= finalized_slot (10 <= 64), which routes + // through `get_block_roots_from_store` — the code path with the bug. + let skip_slots: HashSet<u64> = [5, 6].into_iter().collect(); + let mut rig = TestRig::new_with_skip_slots(128, &skip_slots).await; + + let all_custody_columns = rig.chain.custody_columns_for_epoch(Some(Epoch::new(0))); + let requested_column = vec![all_custody_columns[0]]; + + // Request a range that spans the skip slots (slots 0 through 9). + let start_slot = 0; + let slot_count = 10; + + rig.network_beacon_processor + .send_data_columns_by_range_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + DataColumnsByRangeRequest { + start_slot, + count: slot_count, + columns: requested_column.clone(), + }, + ) + .unwrap(); + + // Collect block roots from all data column responses. + let mut block_roots: Vec<Hash256> = Vec::new(); + + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::DataColumnsByRange(data_column), + inbound_request_id: _, + } = next + { + if let Some(column) = data_column { + block_roots.push(column.block_root()); + } else { + break; + } + } else { + panic!("unexpected message {:?}", next); + } + } + + assert!( + !block_roots.is_empty(), + "Should have received at least some data columns" + ); + + // Before the fix, skip slots caused the same block root to appear multiple times + // (once per skip slot) because .unique() on (Hash256, Slot) tuples didn't deduplicate. + let unique_roots: HashSet<_> = block_roots.iter().collect(); + assert_eq!( + block_roots.len(), + unique_roots.len(), + "Response contained duplicate block roots: got {} columns but only {} unique roots", + block_roots.len(), + unique_roots.len(), + ); +} + +/// Create a test `SignedExecutionPayloadEnvelope` with the given slot and beacon block root. +fn make_test_payload_envelope( + slot: Slot, + beacon_block_root: Hash256, +) -> SignedExecutionPayloadEnvelope<E> { + SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + slot_number: slot, + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root, + parent_beacon_block_root: Hash256::ZERO, + }, + signature: Signature::empty(), + } +} + +#[tokio::test] +async fn test_payload_envelopes_by_range() { + // Only test when Gloas fork is scheduled + if test_spec::<E>().gloas_fork_epoch.is_none() { + return; + }; + + let mut rig = TestRig::new(64).await; + let start_slot = 0; + let slot_count = 32; + + // Manually store payload envelopes for each block in the range + let mut expected_roots = Vec::new(); + for slot in start_slot..slot_count { + if let Some(root) = rig + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + { + let envelope = make_test_payload_envelope(Slot::new(slot), root); + rig.chain + .store + .put_payload_envelope(&root, &envelope) + .unwrap(); + expected_roots.push(root); + } + } + + rig.enqueue_payload_envelopes_by_range_request(start_slot, slot_count); + + let mut actual_roots = Vec::new(); + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRange(envelope), + inbound_request_id: _, + } = next + { + if let Some(env) = envelope { + actual_roots.push(env.beacon_block_root()); + } else { + break; + } + } else if let NetworkMessage::SendErrorResponse { .. } = next { + // Error response terminates the stream + break; + } else { + panic!("unexpected message {:?}", next); + } + } + assert_eq!(expected_roots, actual_roots); +} + +#[tokio::test] +async fn test_payload_envelopes_by_root() { + // Only test when Gloas fork is scheduled + if test_spec::<E>().gloas_fork_epoch.is_none() { + return; + }; + + let mut rig = TestRig::new(64).await; + + let block_root = rig + .chain + .block_root_at_slot(Slot::new(1), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + + // Manually store a payload envelope for this block + let envelope = make_test_payload_envelope(Slot::new(1), block_root); + rig.chain + .store + .put_payload_envelope(&block_root, &envelope) + .unwrap(); + + let roots = RuntimeVariableList::new(vec![block_root], 1).unwrap(); + rig.enqueue_payload_envelopes_by_root_request(roots); + + let mut actual_roots = Vec::new(); + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRoot(envelope), + inbound_request_id: _, + } = next + { + if let Some(env) = envelope { + actual_roots.push(env.beacon_block_root()); + } else { + break; + } + } else { + panic!("unexpected message {:?}", next); + } + } + assert_eq!(vec![block_root], actual_roots); +} + +#[tokio::test] +async fn test_payload_envelopes_by_root_unknown_root_returns_empty() { + // Only test when Gloas fork is scheduled + if test_spec::<E>().gloas_fork_epoch.is_none() { + return; + }; + + let mut rig = TestRig::new(64).await; + + // Request envelope for a root that has no stored envelope + let block_root = rig + .chain + .block_root_at_slot(Slot::new(1), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + + // Don't store any envelope — the handler should return 0 envelopes + let roots = RuntimeVariableList::new(vec![block_root], 1).unwrap(); + rig.enqueue_payload_envelopes_by_root_request(roots); + + let mut actual_count = 0; + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRoot(envelope), + inbound_request_id: _, + } = next + { + if envelope.is_some() { + actual_count += 1; + } else { + break; + } + } else { + panic!("unexpected message {:?}", next); + } + } + assert_eq!(0, actual_count); +} + +#[tokio::test] +async fn test_payload_envelopes_by_range_no_duplicates_with_skip_slots() { + // Only test when Gloas fork is scheduled + if test_spec::<E>().gloas_fork_epoch.is_none() { + return; + }; + + // Build a chain of 128 slots (4 epochs) with skip slots at positions 5 and 6. + let skip_slots: HashSet<u64> = [5, 6].into_iter().collect(); + let mut rig = TestRig::new_with_skip_slots(128, &skip_slots).await; + + let start_slot = 0u64; + let slot_count = 10u64; + + // Store payload envelopes for all blocks in the range (skipping the skip slots) + for slot in start_slot..slot_count { + if let Some(root) = rig + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + { + let envelope = make_test_payload_envelope(Slot::new(slot), root); + rig.chain + .store + .put_payload_envelope(&root, &envelope) + .unwrap(); + } + } + + rig.enqueue_payload_envelopes_by_range_request(start_slot, slot_count); + + let mut beacon_block_roots: Vec<Hash256> = Vec::new(); + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRange(envelope), + inbound_request_id: _, + } = next + { + if let Some(env) = envelope { + beacon_block_roots.push(env.beacon_block_root()); + } else { + break; + } + } else if let NetworkMessage::SendErrorResponse { .. } = next { + break; + } else { + panic!("unexpected message {:?}", next); + } + } + + assert!( + !beacon_block_roots.is_empty(), + "Should have received at least some payload envelopes" + ); + + // Skip slots should not cause duplicate envelopes for the same block root + let unique_roots: HashSet<_> = beacon_block_roots.iter().collect(); + assert_eq!( + beacon_block_roots.len(), + unique_roots.len(), + "Response contained duplicate block roots: got {} envelopes but only {} unique roots", + beacon_block_roots.len(), + unique_roots.len(), + ); +} + +// TODO(ePBS): Add integration tests for envelope deferral (UnknownBlockForEnvelope): +// 1. Gossip envelope arrives before its block → queued via UnknownBlockForEnvelope +// 2. Block imported → envelope released and processed successfully +// 3. Timeout path → envelope released and re-verified diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index f8b2d61ce6..4b2ef84f1d 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -14,17 +14,19 @@ use beacon_processor::{BeaconProcessorSend, DuplicateCache}; use futures::prelude::*; use lighthouse_network::rpc::*; use lighthouse_network::{ - MessageId, NetworkGlobals, PeerId, PubsubMessage, Response, + GossipTopic, MessageId, NetworkGlobals, PeerId, PubsubMessage, Response, service::api_types::{AppRequestId, SyncRequestId}, }; use logging::TimeLatch; use logging::crit; +use slot_clock::{SlotClock, timestamp_now}; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{debug, error, trace, warn}; -use types::{BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, SignedBeaconBlock}; +use types::{ + BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, PartialDataColumn, SignedBeaconBlock, +}; /// Handles messages from the network and routes them to the appropriate service to be handled. pub struct Router<T: BeaconChainTypes> { @@ -69,6 +71,8 @@ pub enum RouterMessage<E: EthSpec> { /// message, the message itself and a bool which indicates if the message should be processed /// by the beacon chain after successful verification. PubsubMessage(MessageId, PeerId, PubsubMessage<E>, bool), + /// A partial data column sidecar has been received via gossipsub partial protocol. + PartialDataColumnSidecar(PeerId, Box<PartialDataColumn<E>>, GossipTopic), /// The peer manager has requested we re-status a peer. StatusPeer(PeerId), /// The peer has an updated custody group count from METADATA. @@ -180,6 +184,16 @@ impl<T: BeaconChainTypes> Router<T> { RouterMessage::PubsubMessage(id, peer_id, gossip, should_process) => { self.handle_gossip(id, peer_id, gossip, should_process); } + RouterMessage::PartialDataColumnSidecar(peer_id, column, topic) => self + .handle_beacon_processor_send_result( + self.network_beacon_processor + .send_gossip_partial_data_column_sidecar( + peer_id, + column, + self.chain.slot_clock.now_duration().unwrap_or_default(), + topic, + ), + ), } } @@ -229,6 +243,24 @@ impl<T: BeaconChainTypes> Router<T> { request, ), ), + RequestType::PayloadEnvelopesByRoot(request) => self + .handle_beacon_processor_send_result( + self.network_beacon_processor + .send_payload_envelopes_by_roots_request( + peer_id, + inbound_request_id, + request, + ), + ), + RequestType::PayloadEnvelopesByRange(request) => self + .handle_beacon_processor_send_result( + self.network_beacon_processor + .send_payload_envelopes_by_range_request( + peer_id, + inbound_request_id, + request, + ), + ), RequestType::BlobsByRange(request) => self.handle_beacon_processor_send_result( self.network_beacon_processor.send_blobs_by_range_request( peer_id, @@ -309,6 +341,11 @@ impl<T: BeaconChainTypes> Router<T> { Response::DataColumnsByRange(data_column) => { self.on_data_columns_by_range_response(peer_id, app_request_id, data_column); } + // TODO(EIP-7732): implement outgoing payload envelopes by range and root + // responses once sync manager requests them. + Response::PayloadEnvelopesByRoot(_) | Response::PayloadEnvelopesByRange(_) => { + debug!("Requesting envelopes by root and by range not supported yet"); + } // Light client responses should not be received Response::LightClientBootstrap(_) | Response::LightClientOptimisticUpdate(_) @@ -328,6 +365,7 @@ impl<T: BeaconChainTypes> Router<T> { gossip_message: PubsubMessage<T::EthSpec>, should_process: bool, ) { + let seen_timestamp = self.chain.slot_clock.now_duration().unwrap_or_default(); match gossip_message { PubsubMessage::AggregateAndProofAttestation(aggregate_and_proof) => self .handle_beacon_processor_send_result( @@ -335,7 +373,7 @@ impl<T: BeaconChainTypes> Router<T> { message_id, peer_id, *aggregate_and_proof, - timestamp_now(), + seen_timestamp, ), ), PubsubMessage::Attestation(subnet_attestation) => self @@ -346,7 +384,7 @@ impl<T: BeaconChainTypes> Router<T> { subnet_attestation.1, subnet_attestation.0, should_process, - timestamp_now(), + seen_timestamp, ), ), PubsubMessage::BeaconBlock(block) => self.handle_beacon_processor_send_result( @@ -355,7 +393,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, self.network_globals.client(&peer_id), block, - timestamp_now(), + seen_timestamp, ), ), PubsubMessage::BlobSidecar(data) => { @@ -367,7 +405,7 @@ impl<T: BeaconChainTypes> Router<T> { self.network_globals.client(&peer_id), blob_index, blob_sidecar, - timestamp_now(), + seen_timestamp, ), ) } @@ -380,7 +418,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, subnet_id, column_sidecar, - timestamp_now(), + seen_timestamp, ), ) } @@ -427,7 +465,7 @@ impl<T: BeaconChainTypes> Router<T> { message_id, peer_id, *contribution_and_proof, - timestamp_now(), + seen_timestamp, ), ) } @@ -442,7 +480,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, sync_committtee_msg.1, sync_committtee_msg.0, - timestamp_now(), + seen_timestamp, ), ) } @@ -457,7 +495,7 @@ impl<T: BeaconChainTypes> Router<T> { message_id, peer_id, *light_client_finality_update, - timestamp_now(), + seen_timestamp, ), ) } @@ -473,7 +511,7 @@ impl<T: BeaconChainTypes> Router<T> { message_id, peer_id, *light_client_optimistic_update, - timestamp_now(), + seen_timestamp, ), ) } @@ -500,6 +538,50 @@ impl<T: BeaconChainTypes> Router<T> { ), ) } + PubsubMessage::ExecutionPayload(signed_execution_payload_envelope) => { + trace!(%peer_id, "Received a signed execution payload envelope"); + self.handle_beacon_processor_send_result( + self.network_beacon_processor.send_gossip_execution_payload( + message_id, + peer_id, + signed_execution_payload_envelope, + seen_timestamp, + ), + ) + } + PubsubMessage::PayloadAttestation(payload_attestation_message) => { + trace!(%peer_id, "Received a payload attestation message"); + self.handle_beacon_processor_send_result( + self.network_beacon_processor + .send_gossip_payload_attestation( + message_id, + peer_id, + payload_attestation_message, + ), + ) + } + PubsubMessage::ExecutionPayloadBid(execution_payload_bid) => { + trace!(%peer_id, "Received a signed execution payload bid"); + self.handle_beacon_processor_send_result( + self.network_beacon_processor + .send_gossip_execution_payload_bid( + message_id, + peer_id, + execution_payload_bid, + ), + ) + } + PubsubMessage::ProposerPreferences(proposer_preferences) => { + trace!(%peer_id, "Received signed proposer preferences"); + self.handle_beacon_processor_send_result( + self.network_beacon_processor + .send_gossip_proposer_preferences( + message_id, + peer_id, + proposer_preferences, + ), + ) + } } } @@ -589,7 +671,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, sync_request_id, beacon_block, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -609,7 +691,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, sync_request_id, blob_sidecar, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All blobs by range responses should belong to sync"); @@ -646,7 +728,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, sync_request_id, beacon_block, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -680,7 +762,7 @@ impl<T: BeaconChainTypes> Router<T> { sync_request_id, peer_id, blob_sidecar, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -714,7 +796,7 @@ impl<T: BeaconChainTypes> Router<T> { sync_request_id, peer_id, data_column, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -734,7 +816,7 @@ impl<T: BeaconChainTypes> Router<T> { peer_id, sync_request_id, data_column, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All data columns by range responses should belong to sync"); @@ -802,9 +884,3 @@ impl<E: EthSpec> HandlerNetworkContext<E> { }) } } - -fn timestamp_now() -> Duration { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) -} diff --git a/beacon_node/network/src/service.rs b/beacon_node/network/src/service.rs index 0869b442ae..ce54ffc38f 100644 --- a/beacon_node/network/src/service.rs +++ b/beacon_node/network/src/service.rs @@ -39,8 +39,8 @@ use tokio::time::Sleep; use tracing::{debug, error, info, trace, warn}; use typenum::Unsigned; use types::{ - EthSpec, ForkContext, Slot, SubnetId, SyncCommitteeSubscription, SyncSubnetId, - ValidatorSubscription, + EthSpec, ForkContext, PartialDataColumn, PartialDataColumnHeader, Slot, SubnetId, + SyncCommitteeSubscription, SyncSubnetId, ValidatorSubscription, }; mod tests; @@ -83,6 +83,11 @@ pub enum NetworkMessage<E: EthSpec> { }, /// Publish a list of messages to the gossipsub protocol. Publish { messages: Vec<PubsubMessage<E>> }, + /// Publish partial data column sidecars via the partial gossipsub protocol. + PublishPartialColumns { + columns: Vec<Arc<PartialDataColumn<E>>>, + header: Arc<PartialDataColumnHeader<E>>, + }, /// Validates a received gossipsub message. This will propagate the message on the network. ValidationResult { /// The peer that sent us the message. We don't send back to this peer. @@ -92,6 +97,13 @@ pub enum NetworkMessage<E: EthSpec> { /// The result of the validation validation_result: MessageAcceptance, }, + /// Reports validation failure of a partial message. + PartialValidationFailure { + /// The peer that sent us the message. + propagation_source: PeerId, + /// The topic of the message. + gossip_topic: GossipTopic, + }, /// Reports a peer to the peer manager for performing an action. ReportPeer { peer_id: PeerId, @@ -540,7 +552,7 @@ impl<T: BeaconChainTypes> NetworkService<T> { let subnet_id = subnet_and_attestation.0; let attestation = &subnet_and_attestation.1; // checks if we have an aggregator for the slot. If so, we should process - // the attestation, else we just just propagate the Attestation. + // the attestation, else we just propagate the Attestation. let should_process = self.subnet_service.should_process_attestation( Subnet::Attestation(subnet_id), &attestation.data, @@ -560,6 +572,15 @@ impl<T: BeaconChainTypes> NetworkService<T> { } } } + NetworkEvent::PartialDataColumnSidecar { + source, + column, + topic, + } => { + self.send_to_router(RouterMessage::PartialDataColumnSidecar( + source, column, topic, + )); + } NetworkEvent::NewListenAddr(multiaddr) => { self.network_globals .listen_multiaddrs @@ -640,11 +661,19 @@ impl<T: BeaconChainTypes> NetworkService<T> { validation_result, ); } + NetworkMessage::PartialValidationFailure { + propagation_source, + gossip_topic, + } => { + self.libp2p + .report_partial_message_validation_failure(propagation_source, gossip_topic); + } NetworkMessage::Publish { messages } => { let mut topic_kinds = Vec::new(); for message in &messages { - if !topic_kinds.contains(&message.kind()) { - topic_kinds.push(message.kind()); + let kind = message.kind(); + if !topic_kinds.contains(&kind) { + topic_kinds.push(kind); } } debug!( @@ -654,6 +683,9 @@ impl<T: BeaconChainTypes> NetworkService<T> { ); self.libp2p.publish(messages); } + NetworkMessage::PublishPartialColumns { columns, header } => { + self.libp2p.publish_partial(columns, header); + } NetworkMessage::ReportPeer { peer_id, action, @@ -862,9 +894,11 @@ impl<T: BeaconChainTypes> NetworkService<T> { self.next_digest_update = Box::pin(next_digest_delay(&self.beacon_chain).into()); // Set the next_unsubscribe delay. - let epoch_duration = - self.beacon_chain.spec.seconds_per_slot * T::EthSpec::slots_per_epoch(); - let unsubscribe_delay = Duration::from_secs(UNSUBSCRIBE_DELAY_EPOCHS * epoch_duration); + let unsubscribe_delay = Duration::from_secs( + UNSUBSCRIBE_DELAY_EPOCHS + * self.beacon_chain.spec.get_slot_duration().as_secs() + * T::EthSpec::slots_per_epoch(), + ); // Update the `next_topic_subscriptions` timer if the next change in the fork digest is known. self.next_topic_subscriptions = @@ -915,7 +949,7 @@ fn next_topic_subscriptions_delay<T: BeaconChainTypes>( ) -> Option<tokio::time::Sleep> { if let Some((_, duration_to_epoch)) = beacon_chain.duration_to_next_digest() { let duration_to_subscription = duration_to_epoch.saturating_sub(Duration::from_secs( - beacon_chain.spec.seconds_per_slot * SUBSCRIBE_DELAY_SLOTS, + beacon_chain.spec.get_slot_duration().as_secs() * SUBSCRIBE_DELAY_SLOTS, )); if !duration_to_subscription.is_zero() { return Some(tokio::time::sleep(duration_to_subscription)); diff --git a/beacon_node/network/src/service/tests.rs b/beacon_node/network/src/service/tests.rs index 5fabab19ea..417947150c 100644 --- a/beacon_node/network/src/service/tests.rs +++ b/beacon_node/network/src/service/tests.rs @@ -6,6 +6,7 @@ use beacon_chain::BeaconChainTypes; use beacon_chain::test_utils::BeaconChainHarness; use beacon_processor::{BeaconProcessorChannels, BeaconProcessorConfig}; use futures::StreamExt; +use libp2p::gossipsub; use lighthouse_network::identity::secp256k1; use lighthouse_network::types::{GossipEncoding, GossipKind}; use lighthouse_network::{Enr, GossipTopic}; diff --git a/beacon_node/network/src/subnet_service/mod.rs b/beacon_node/network/src/subnet_service/mod.rs index be491e56d3..008e7ab9ac 100644 --- a/beacon_node/network/src/subnet_service/mod.rs +++ b/beacon_node/network/src/subnet_service/mod.rs @@ -198,13 +198,6 @@ impl<T: BeaconChainTypes> SubnetService<T> { self.permanent_attestation_subscriptions.iter() } - /// Returns whether we are subscribed to a subnet for testing purposes. - #[cfg(test)] - pub(crate) fn is_subscribed(&self, subnet: &Subnet) -> bool { - self.subscriptions.contains_key(subnet) - || self.permanent_attestation_subscriptions.contains(subnet) - } - /// Returns whether we are subscribed to a permanent subnet for testing purposes. #[cfg(test)] pub(crate) fn is_subscribed_permanent(&self, subnet: &Subnet) -> bool { diff --git a/beacon_node/network/src/subnet_service/tests/mod.rs b/beacon_node/network/src/subnet_service/tests/mod.rs index bee6569b7b..619154d738 100644 --- a/beacon_node/network/src/subnet_service/tests/mod.rs +++ b/beacon_node/network/src/subnet_service/tests/mod.rs @@ -335,28 +335,26 @@ mod test { // submit the subscriptions subnet_service.validator_subscriptions(vec![sub1, sub2].into_iter()); - // Unsubscription event should happen at slot 2 (since subnet id's are the same, unsubscription event should be at higher slot + 1) - let expected = SubnetServiceMessage::Subscribe(Subnet::Attestation(subnet_id1)); + let subnet = Subnet::Attestation(subnet_id1); - if subnet_service.is_subscribed(&Subnet::Attestation(subnet_id1)) { - // If we are permanently subscribed to this subnet, we won't see a subscribe message - let _ = get_events_until_num_slots(&mut subnet_service, None, 1).await; + if subnet_service.is_subscribed_permanent(&subnet) { + // If permanently subscribed, no Subscribe/Unsubscribe events will be generated + let events = get_events_until_num_slots(&mut subnet_service, None, 3).await; + assert!(events.is_empty()); } else { - let subscription = get_events_until_num_slots(&mut subnet_service, None, 1).await; - assert_eq!(subscription, [expected]); + // Wait 1 slot: expect a single Subscribe event (no duplicate for the same subnet). + let events = get_events_until_num_slots(&mut subnet_service, None, 1).await; + assert_eq!(events, [SubnetServiceMessage::Subscribe(subnet)]); + + // Wait for the Unsubscribe event after subscription_slot2 expires. + // Use a longer timeout because the test doesn't start exactly at a slot + // boundary, so the previous 1-slot wait may end partway through slot 1, + // leaving insufficient time to catch the Unsubscribe within another 1 slot. + let events = get_events_until_num_slots(&mut subnet_service, Some(1), 3).await; + assert_eq!(events, [SubnetServiceMessage::Unsubscribe(subnet)]); } - // Get event for 1 more slot duration, we should get the unsubscribe event now. - let unsubscribe_event = get_events_until_num_slots(&mut subnet_service, None, 1).await; - - // If the long lived and short lived subnets are different, we should get an unsubscription - // event. - let expected = SubnetServiceMessage::Unsubscribe(Subnet::Attestation(subnet_id1)); - if !subnet_service.is_subscribed(&Subnet::Attestation(subnet_id1)) { - assert_eq!([expected], unsubscribe_event[..]); - } - - // Should no longer be subscribed to any short lived subnets after unsubscription. + // Should no longer be subscribed to any short lived subnets after unsubscription. assert_eq!(subnet_service.subscriptions().count(), 0); } diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index 6c0cbd7e55..0f80138d24 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -8,16 +8,18 @@ //! If a batch fails, the backfill sync cannot progress. In this scenario, we mark the backfill //! sync as failed, log an error and attempt to retry once a new peer joins the node. +use crate::metrics; use crate::network_beacon_processor::ChainSegmentProcessId; use crate::sync::batch::{ - BatchConfig, BatchId, BatchInfo, BatchOperationOutcome, BatchProcessingResult, BatchState, + BatchConfig, BatchId, BatchInfo, BatchMetricsState, BatchOperationOutcome, + BatchProcessingResult, BatchState, }; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::manager::BatchProcessResult; use crate::sync::network_context::{ RangeRequestId, RpcRequestSendError, RpcResponseError, SyncNetworkContext, }; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::{BeaconChain, BeaconChainTypes}; use lighthouse_network::service::api_types::Id; use lighthouse_network::types::{BackFillState, NetworkGlobals}; @@ -31,6 +33,7 @@ use std::collections::{ use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use std::sync::Arc; +use strum::IntoEnumIterator; use tracing::{debug, error, info, warn}; use types::{ColumnIndex, Epoch, EthSpec}; @@ -52,7 +55,7 @@ const MAX_BATCH_DOWNLOAD_ATTEMPTS: u8 = 10; /// after `MAX_BATCH_PROCESSING_ATTEMPTS` times, it is considered faulty. const MAX_BATCH_PROCESSING_ATTEMPTS: u8 = 10; -type RpcBlocks<E> = Vec<RpcBlock<E>>; +type RpcBlocks<E> = Vec<RangeSyncBlock<E>>; type BackFillBatchInfo<E> = BatchInfo<E, BackFillBatchConfig<E>, RpcBlocks<E>>; @@ -346,7 +349,7 @@ impl<T: BeaconChainTypes> BackFillSync<T> { } } CouplingError::BlobPeerFailure(msg) => { - tracing::debug!(?batch_id, msg, "Blob peer failure"); + debug!(?batch_id, msg, "Blob peer failure"); } CouplingError::InternalError(msg) => { error!(?batch_id, msg, "Block components coupling internal error"); @@ -387,7 +390,7 @@ impl<T: BeaconChainTypes> BackFillSync<T> { batch_id: BatchId, peer_id: &PeerId, request_id: Id, - blocks: Vec<RpcBlock<T::EthSpec>>, + blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) -> Result<ProcessResult, BackFillError> { // check if we have this batch let Some(batch) = self.batches.get_mut(&batch_id) else { @@ -1071,7 +1074,7 @@ impl<T: BeaconChainTypes> BackFillSync<T> { .iter() .filter(|&(_epoch, batch)| in_buffer(batch)) .count() - > BACKFILL_BATCH_BUFFER_SIZE as usize + >= BACKFILL_BATCH_BUFFER_SIZE as usize { return None; } @@ -1181,6 +1184,21 @@ impl<T: BeaconChainTypes> BackFillSync<T> { .epoch(T::EthSpec::slots_per_epoch()) } + pub fn register_metrics(&self) { + for state in BatchMetricsState::iter() { + let count = self + .batches + .values() + .filter(|b| b.state().metrics_state() == state) + .count(); + metrics::set_gauge_vec( + &metrics::SYNCING_CHAIN_BATCHES, + &["backfill", state.into()], + count as i64, + ); + } + } + /// Updates the global network state indicating the current state of a backfill sync. fn set_state(&self, state: BackFillState) { *self.network_globals.backfill_state.write() = state; diff --git a/beacon_node/network/src/sync/batch.rs b/beacon_node/network/src/sync/batch.rs index 8de386f5be..10af1bf503 100644 --- a/beacon_node/network/src/sync/batch.rs +++ b/beacon_node/network/src/sync/batch.rs @@ -1,4 +1,4 @@ -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use educe::Educe; use lighthouse_network::PeerId; use lighthouse_network::rpc::methods::BlocksByRangeRequest; @@ -10,10 +10,22 @@ use std::marker::PhantomData; use std::ops::Sub; use std::time::Duration; use std::time::Instant; -use strum::Display; +use strum::{Display, EnumIter, IntoStaticStr}; use types::Slot; use types::{DataColumnSidecarList, Epoch, EthSpec}; +/// Batch states used as metrics labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum BatchMetricsState { + AwaitingDownload, + Downloading, + AwaitingProcessing, + Processing, + AwaitingValidation, + Failed, +} + pub type BatchId = Epoch; /// Type of expected batch. @@ -142,6 +154,18 @@ impl<D: Hash> BatchState<D> { pub fn poison(&mut self) -> BatchState<D> { std::mem::replace(self, BatchState::Poisoned) } + + /// Returns the metrics state for this batch. + pub fn metrics_state(&self) -> BatchMetricsState { + match self { + BatchState::AwaitingDownload => BatchMetricsState::AwaitingDownload, + BatchState::Downloading(_) => BatchMetricsState::Downloading, + BatchState::AwaitingProcessing(..) => BatchMetricsState::AwaitingProcessing, + BatchState::Processing(_) => BatchMetricsState::Processing, + BatchState::AwaitingValidation(_) => BatchMetricsState::AwaitingValidation, + BatchState::Poisoned | BatchState::Failed => BatchMetricsState::Failed, + } + } } impl<E: EthSpec, B: BatchConfig, D: Hash> BatchInfo<E, B, D> { @@ -213,6 +237,9 @@ impl<E: EthSpec, B: BatchConfig, D: Hash> BatchInfo<E, B, D> { /// After different operations over a batch, this could be in a state that allows it to /// continue, or in failed state. When the batch has failed, we check if it did mainly due to /// processing failures. In this case the batch is considered failed and faulty. + /// + /// When failure counts are equal, `blacklist` is `false` — we assume network issues over + /// peer fault when the evidence is ambiguous. pub fn outcome(&self) -> BatchOperationOutcome { match self.state { BatchState::Poisoned => unreachable!("Poisoned batch"), @@ -255,8 +282,10 @@ impl<E: EthSpec, B: BatchConfig, D: Hash> BatchInfo<E, B, D> { /// Mark the batch as failed and return whether we can attempt a re-download. /// /// This can happen if a peer disconnects or some error occurred that was not the peers fault. - /// The `peer` parameter, when set to None, does not increment the failed attempts of - /// this batch and register the peer, rather attempts a re-download. + /// The `peer` parameter, when set to `None`, still counts toward + /// `max_batch_download_attempts` (to prevent infinite retries on persistent failures) + /// but does not register a peer in `failed_peers()`. Use + /// [`Self::downloading_to_awaiting_download`] to retry without counting a failed attempt. #[must_use = "Batch may have failed"] pub fn download_failed( &mut self, @@ -272,7 +301,6 @@ impl<E: EthSpec, B: BatchConfig, D: Hash> BatchInfo<E, B, D> { { BatchState::Failed } else { - // drop the blocks BatchState::AwaitingDownload }; Ok(self.outcome()) @@ -421,7 +449,7 @@ impl<E: EthSpec, B: BatchConfig, D: Hash> BatchInfo<E, B, D> { } // BatchInfo implementations for RangeSync -impl<E: EthSpec, B: BatchConfig> BatchInfo<E, B, Vec<RpcBlock<E>>> { +impl<E: EthSpec, B: BatchConfig> BatchInfo<E, B, Vec<RangeSyncBlock<E>>> { /// Returns a BlocksByRange request associated with the batch. pub fn to_blocks_by_range_request(&self) -> (BlocksByRangeRequest, ByRangeRequestType) { ( @@ -524,3 +552,196 @@ impl<D: Hash> BatchState<D> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::range_sync::RangeSyncBatchConfig; + use types::MinimalEthSpec; + + type Cfg = RangeSyncBatchConfig<MinimalEthSpec>; + type TestBatch = BatchInfo<MinimalEthSpec, Cfg, Vec<u64>>; + + fn max_dl() -> u8 { + Cfg::max_batch_download_attempts() + } + + fn max_proc() -> u8 { + Cfg::max_batch_processing_attempts() + } + + fn new_batch() -> TestBatch { + BatchInfo::new(&Epoch::new(0), 1, ByRangeRequestType::Blocks) + } + + fn peer() -> PeerId { + PeerId::random() + } + + fn advance_to_processing(batch: &mut TestBatch, req_id: Id, peer_id: PeerId) { + batch.start_downloading(req_id).unwrap(); + batch.download_completed(vec![1, 2, 3], peer_id).unwrap(); + batch.start_processing().unwrap(); + } + + fn advance_to_awaiting_validation(batch: &mut TestBatch, req_id: Id, peer_id: PeerId) { + advance_to_processing(batch, req_id, peer_id); + batch + .processing_completed(BatchProcessingResult::Success) + .unwrap(); + } + + #[test] + fn happy_path_lifecycle() { + let mut batch = new_batch(); + let p = peer(); + + assert!(matches!(batch.state(), BatchState::AwaitingDownload)); + + batch.start_downloading(1).unwrap(); + assert!(matches!(batch.state(), BatchState::Downloading(1))); + + batch.download_completed(vec![10, 20], p).unwrap(); + assert!(matches!(batch.state(), BatchState::AwaitingProcessing(..))); + + let (data, _duration) = batch.start_processing().unwrap(); + assert_eq!(data, vec![10, 20]); + assert!(matches!(batch.state(), BatchState::Processing(..))); + + let outcome = batch + .processing_completed(BatchProcessingResult::Success) + .unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + assert!(matches!(batch.state(), BatchState::AwaitingValidation(..))); + } + + #[test] + fn download_failures_count_toward_limit() { + let mut batch = new_batch(); + + for i in 1..max_dl() as Id { + batch.start_downloading(i).unwrap(); + let outcome = batch.download_failed(Some(peer())).unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + + // Next failure hits the limit + batch.start_downloading(max_dl() as Id).unwrap(); + let outcome = batch.download_failed(Some(peer())).unwrap(); + assert!(matches!( + outcome, + BatchOperationOutcome::Failed { blacklist: false } + )); + } + + #[test] + fn download_failed_none_counts_but_does_not_blame_peer() { + let mut batch = new_batch(); + + // None still counts toward the limit (prevents infinite retry on persistent + // network failures), but doesn't register a peer in failed_peers(). + for i in 0..max_dl() as Id { + batch.start_downloading(i).unwrap(); + batch.download_failed(None).unwrap(); + } + assert!(matches!(batch.state(), BatchState::Failed)); + assert!(batch.failed_peers().is_empty()); + } + + #[test] + fn faulty_processing_failures_count_toward_limit() { + let mut batch = new_batch(); + + for i in 1..max_proc() as Id { + advance_to_processing(&mut batch, i, peer()); + let outcome = batch + .processing_completed(BatchProcessingResult::FaultyFailure) + .unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + + // Next faulty failure: limit reached + advance_to_processing(&mut batch, max_proc() as Id, peer()); + let outcome = batch + .processing_completed(BatchProcessingResult::FaultyFailure) + .unwrap(); + assert!(matches!( + outcome, + BatchOperationOutcome::Failed { blacklist: true } + )); + } + + #[test] + fn non_faulty_processing_failures_never_exhaust_batch() { + let mut batch = new_batch(); + + // Well past both limits — non-faulty failures should never cause failure + let iterations = (max_dl() + max_proc()) as Id * 2; + for i in 0..iterations { + advance_to_processing(&mut batch, i, peer()); + let outcome = batch + .processing_completed(BatchProcessingResult::NonFaultyFailure) + .unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + // Non-faulty failures also don't register peers as failed + assert!(batch.failed_peers().is_empty()); + } + + #[test] + fn validation_failures_count_toward_processing_limit() { + let mut batch = new_batch(); + + for i in 1..max_proc() as Id { + advance_to_awaiting_validation(&mut batch, i, peer()); + let outcome = batch.validation_failed().unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + + advance_to_awaiting_validation(&mut batch, max_proc() as Id, peer()); + let outcome = batch.validation_failed().unwrap(); + assert!(matches!( + outcome, + BatchOperationOutcome::Failed { blacklist: true } + )); + } + + #[test] + fn mixed_failure_types_interact_correctly() { + let mut batch = new_batch(); + let mut req_id: Id = 0; + let mut next_id = || { + req_id += 1; + req_id + }; + + // One download failure + batch.start_downloading(next_id()).unwrap(); + batch.download_failed(Some(peer())).unwrap(); + + // One faulty processing failure (requires a successful download first) + advance_to_processing(&mut batch, next_id(), peer()); + batch + .processing_completed(BatchProcessingResult::FaultyFailure) + .unwrap(); + + // One non-faulty processing failure + advance_to_processing(&mut batch, next_id(), peer()); + batch + .processing_completed(BatchProcessingResult::NonFaultyFailure) + .unwrap(); + assert!(matches!(batch.state(), BatchState::AwaitingDownload)); + + // Fill remaining download failures to hit the limit + for _ in 1..max_dl() { + batch.start_downloading(next_id()).unwrap(); + batch.download_failed(Some(peer())).unwrap(); + } + + // Download failures > processing failures → blacklist: false + assert!(matches!( + batch.outcome(), + BatchOperationOutcome::Failed { blacklist: false } + )); + } +} diff --git a/beacon_node/network/src/sync/block_lookups/common.rs b/beacon_node/network/src/sync/block_lookups/common.rs index c6b0519087..edd99345b4 100644 --- a/beacon_node/network/src/sync/block_lookups/common.rs +++ b/beacon_node/network/src/sync/block_lookups/common.rs @@ -11,7 +11,7 @@ use lighthouse_network::service::api_types::Id; use parking_lot::RwLock; use std::collections::HashSet; use std::sync::Arc; -use types::blob_sidecar::FixedBlobSidecarList; +use types::data::FixedBlobSidecarList; use types::{DataColumnSidecarList, SignedBeaconBlock}; use super::SingleLookupId; diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index f8ffd298ca..3929f74aa0 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -45,7 +45,7 @@ use std::sync::Arc; use std::time::Duration; use store::Hash256; use tracing::{debug, error, warn}; -use types::{BlobSidecar, DataColumnSidecar, EthSpec, SignedBeaconBlock}; +use types::{EthSpec, SignedBeaconBlock}; pub mod common; pub mod parent_chain; @@ -77,18 +77,21 @@ const LOOKUP_MAX_DURATION_NO_PEERS_SECS: u64 = 10; /// take at most 2 GB. 200 lookups allow 3 parallel chains of depth 64 (current maximum). const MAX_LOOKUPS: usize = 200; +/// The values for `Blob`, `DataColumn` and `PartialDataColumn` is the parent root of the column. pub enum BlockComponent<E: EthSpec> { Block(DownloadResult<Arc<SignedBeaconBlock<E>>>), - Blob(DownloadResult<Arc<BlobSidecar<E>>>), - DataColumn(DownloadResult<Arc<DataColumnSidecar<E>>>), + Blob(DownloadResult<Hash256>), + DataColumn(DownloadResult<Hash256>), + PartialDataColumn(DownloadResult<Hash256>), } impl<E: EthSpec> BlockComponent<E> { fn parent_root(&self) -> Hash256 { match self { BlockComponent::Block(block) => block.value.parent_root(), - BlockComponent::Blob(blob) => blob.value.block_parent_root(), - BlockComponent::DataColumn(column) => column.value.block_parent_root(), + BlockComponent::Blob(parent_root) + | BlockComponent::DataColumn(parent_root) + | BlockComponent::PartialDataColumn(parent_root) => parent_root.value, } } fn get_type(&self) -> &'static str { @@ -96,6 +99,7 @@ impl<E: EthSpec> BlockComponent<E> { BlockComponent::Block(_) => "block", BlockComponent::Blob(_) => "blob", BlockComponent::DataColumn(_) => "data_column", + BlockComponent::PartialDataColumn(_) => "partial_data_column", } } } @@ -117,15 +121,24 @@ pub struct BlockLookups<T: BeaconChainTypes> { // TODO: Why not index lookups by block_root? single_block_lookups: FnvHashMap<SingleLookupId, SingleBlockLookup<T>>, + + /// Used for testing assertions + metrics: BlockLookupsMetrics, } #[cfg(test)] use lighthouse_network::service::api_types::Id; #[cfg(test)] -/// Tuple of `SingleLookupId`, requested block root, awaiting parent block root (if any), -/// and list of peers that claim to have imported this set of block components. -pub(crate) type BlockLookupSummary = (Id, Hash256, Option<Hash256>, Vec<PeerId>); +#[derive(Debug)] +pub(crate) struct BlockLookupSummary { + /// Lookup ID + pub id: Id, + /// Requested block root + pub block_root: Hash256, + /// List of peers that claim to have imported this set of block components. + pub peers: Vec<PeerId>, +} impl<T: BeaconChainTypes> BlockLookups<T> { pub fn new() -> Self { @@ -134,9 +147,15 @@ impl<T: BeaconChainTypes> BlockLookups<T> { IGNORED_CHAINS_CACHE_EXPIRY_SECONDS, )), single_block_lookups: Default::default(), + metrics: <_>::default(), } } + #[cfg(test)] + pub(crate) fn metrics(&self) -> &BlockLookupsMetrics { + &self.metrics + } + #[cfg(test)] pub(crate) fn insert_ignored_chain(&mut self, block_root: Hash256) { self.ignored_chains.insert(block_root); @@ -151,7 +170,11 @@ impl<T: BeaconChainTypes> BlockLookups<T> { pub(crate) fn active_single_lookups(&self) -> Vec<BlockLookupSummary> { self.single_block_lookups .iter() - .map(|(id, l)| (*id, l.block_root(), l.awaiting_parent(), l.all_peers())) + .map(|(id, l)| BlockLookupSummary { + id: *id, + block_root: l.block_root(), + peers: l.all_peers(), + }) .collect() } @@ -302,7 +325,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { // attributability. A peer can send us garbage blocks over blocks_by_root, and // then correct blocks via blocks_by_range. - self.drop_lookup_and_children(*lookup_id); + self.drop_lookup_and_children(*lookup_id, "chain_too_long"); } else { // Should never happen error!( @@ -375,7 +398,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { // Lookups contain untrusted data, bound the total count of lookups hold in memory to reduce // the risk of OOM in case of bugs of malicious activity. - if self.single_block_lookups.len() > MAX_LOOKUPS { + if self.single_block_lookups.len() >= MAX_LOOKUPS { warn!(?block_root, "Dropping lookup reached max"); return false; } @@ -410,6 +433,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { "Created block lookup" ); metrics::inc_counter(&metrics::SYNC_LOOKUP_CREATED); + self.metrics.created_lookups += 1; let result = lookup.continue_requests(cx); if self.on_lookup_result(id, result, "new_current_lookup", cx) { @@ -509,8 +533,11 @@ impl<T: BeaconChainTypes> BlockLookups<T> { /* Error responses */ pub fn peer_disconnected(&mut self, peer_id: &PeerId) { - for (_, lookup) in self.single_block_lookups.iter_mut() { + for (id, lookup) in self.single_block_lookups.iter_mut() { lookup.remove_peer(peer_id); + if lookup.has_no_peers() { + debug!(%id, "Lookup has no peers"); + } } } @@ -562,7 +589,8 @@ impl<T: BeaconChainTypes> BlockLookups<T> { let action = match result { BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(_)) - | BlockProcessingResult::Err(BlockError::DuplicateFullyImported(..)) => { + | BlockProcessingResult::Err(BlockError::DuplicateFullyImported(..)) + | BlockProcessingResult::Err(BlockError::GenesisBlock) => { // Successfully imported request_state.on_processing_success()?; Action::Continue @@ -743,6 +771,15 @@ impl<T: BeaconChainTypes> BlockLookups<T> { let lookup_result = if imported { Ok(LookupResult::Completed) } else { + // A lookup may be in the following state: + // - Block awaiting processing from a different source + // - Blobs downloaded processed, and inserted into the da_checker + // + // At this point the block fails processing (e.g. execution engine offline) and it is + // removed from the da_checker. Note that ALL components are removed from the da_checker + // so when we re-download and process the block we get the error + // MissingComponentsAfterAllProcessed and get stuck. + lookup.reset_requests(); lookup.continue_requests(cx) }; let id = *id; @@ -775,14 +812,17 @@ impl<T: BeaconChainTypes> BlockLookups<T> { /// Drops `dropped_id` lookup and all its children recursively. Lookups awaiting a parent need /// the parent to make progress to resolve, therefore we must drop them if the parent is /// dropped. - pub fn drop_lookup_and_children(&mut self, dropped_id: SingleLookupId) { + pub fn drop_lookup_and_children(&mut self, dropped_id: SingleLookupId, reason: &'static str) { if let Some(dropped_lookup) = self.single_block_lookups.remove(&dropped_id) { debug!( id = ?dropped_id, block_root = ?dropped_lookup.block_root(), awaiting_parent = ?dropped_lookup.awaiting_parent(), + reason, "Dropping lookup" ); + metrics::inc_counter_vec(&metrics::SYNC_LOOKUP_DROPPED, &[reason]); + self.metrics.dropped_lookups += 1; let child_lookups = self .single_block_lookups @@ -792,7 +832,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { .collect::<Vec<_>>(); for id in child_lookups { - self.drop_lookup_and_children(id); + self.drop_lookup_and_children(id, reason); } } } @@ -810,8 +850,13 @@ impl<T: BeaconChainTypes> BlockLookups<T> { Ok(LookupResult::Pending) => true, // no action Ok(LookupResult::Completed) => { if let Some(lookup) = self.single_block_lookups.remove(&id) { - debug!(block = ?lookup.block_root(), id, "Dropping completed lookup"); + debug!( + block = ?lookup.block_root(), + id, + "Dropping completed lookup" + ); metrics::inc_counter(&metrics::SYNC_LOOKUP_COMPLETED); + self.metrics.completed_lookups += 1; // Block imported, continue the requests of pending child blocks self.continue_child_lookups(lookup.block_root(), cx); self.update_metrics(); @@ -825,8 +870,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { Err(LookupRequestError::UnknownLookup) => false, Err(error) => { debug!(id, source, ?error, "Dropping lookup on request error"); - metrics::inc_counter_vec(&metrics::SYNC_LOOKUP_DROPPED, &[error.into()]); - self.drop_lookup_and_children(id); + self.drop_lookup_and_children(id, error.into()); self.update_metrics(); false } @@ -893,7 +937,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { %block_root, "Dropping lookup with no peers" ); - self.drop_lookup_and_children(lookup_id); + self.drop_lookup_and_children(lookup_id, "no_peers"); } } @@ -942,7 +986,7 @@ impl<T: BeaconChainTypes> BlockLookups<T> { } metrics::inc_counter(&metrics::SYNC_LOOKUPS_STUCK); - self.drop_lookup_and_children(ancestor_stuck_lookup.id); + self.drop_lookup_and_children(ancestor_stuck_lookup.id, "lookup_stuck"); } } @@ -1018,3 +1062,10 @@ impl<T: BeaconChainTypes> BlockLookups<T> { } } } + +#[derive(Default, Clone, Debug)] +pub(crate) struct BlockLookupsMetrics { + pub created_lookups: usize, + pub dropped_lookups: usize, + pub completed_lookups: usize, +} diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index 46897b2283..23bfd531f0 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -7,7 +7,6 @@ use crate::sync::network_context::{ use beacon_chain::{BeaconChainTypes, BlockProcessStatus}; use educe::Educe; use lighthouse_network::service::api_types::Id; -use lighthouse_tracing::SPAN_SINGLE_BLOCK_LOOKUP; use parking_lot::RwLock; use std::collections::HashSet; use std::fmt::Debug; @@ -16,7 +15,7 @@ use std::time::{Duration, Instant}; use store::Hash256; use strum::IntoStaticStr; use tracing::{Span, debug_span}; -use types::blob_sidecar::FixedBlobSidecarList; +use types::data::FixedBlobSidecarList; use types::{DataColumnSidecarList, EthSpec, SignedBeaconBlock, Slot}; // Dedicated enum for LookupResult to force its usage @@ -93,7 +92,7 @@ impl<T: BeaconChainTypes> SingleBlockLookup<T> { awaiting_parent: Option<Hash256>, ) -> Self { let lookup_span = debug_span!( - SPAN_SINGLE_BLOCK_LOOKUP, + "lh_single_block_lookup", block_root = %requested_block_root, id = id, ); @@ -110,6 +109,12 @@ impl<T: BeaconChainTypes> SingleBlockLookup<T> { } } + /// Reset the status of all internal requests + pub fn reset_requests(&mut self) { + self.block_request_state = BlockRequestState::new(self.block_root); + self.component_requests = ComponentRequests::WaitingForBlock; + } + /// Return the slot of this lookup's block if it's currently cached as `AwaitingProcessing` pub fn peek_downloaded_block_slot(&self) -> Option<Slot> { self.block_request_state @@ -151,7 +156,9 @@ impl<T: BeaconChainTypes> SingleBlockLookup<T> { .block_request_state .state .insert_verified_response(block), - BlockComponent::Blob(_) | BlockComponent::DataColumn(_) => { + BlockComponent::Blob(_) + | BlockComponent::DataColumn(_) + | BlockComponent::PartialDataColumn(_) => { // For now ignore single blobs and columns, as the blob request state assumes all blobs are // attributed to the same peer = the peer serving the remaining blobs. Ignoring this // block component has a minor effect, causing the node to re-request this blob diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index ed9a11a03d..98cf3e0a1f 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -1,5 +1,9 @@ use beacon_chain::{ - block_verification_types::RpcBlock, data_column_verification::CustodyDataColumn, get_block_root, + BeaconChainTypes, + block_verification_types::{AvailableBlockData, RangeSyncBlock}, + data_availability_checker::DataAvailabilityChecker, + data_column_verification::CustodyDataColumn, + get_block_root, }; use lighthouse_network::{ PeerId, @@ -192,19 +196,26 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { /// Returns `None` if not all expected requests have completed. /// Returns `Some(Ok(_))` with valid RPC blocks if all data is present and valid. /// Returns `Some(Err(_))` if there are issues coupling blocks with their data. - pub fn responses( + pub fn responses<T>( &mut self, - spec: &ChainSpec, - ) -> Option<Result<Vec<RpcBlock<E>>, CouplingError>> { + da_checker: Arc<DataAvailabilityChecker<T>>, + spec: Arc<ChainSpec>, + ) -> Option<Result<Vec<RangeSyncBlock<E>>, CouplingError>> + where + T: BeaconChainTypes<EthSpec = E>, + { let Some(blocks) = self.blocks_request.to_finished() else { return None; }; // Increment the attempt once this function returns the response or errors match &mut self.block_data_request { - RangeBlockDataRequest::NoData => { - Some(Self::responses_with_blobs(blocks.to_vec(), vec![], spec)) - } + RangeBlockDataRequest::NoData => Some(Self::responses_with_blobs( + blocks.to_vec(), + vec![], + da_checker, + spec, + )), RangeBlockDataRequest::Blobs(request) => { let Some(blobs) = request.to_finished() else { return None; @@ -212,6 +223,7 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { Some(Self::responses_with_blobs( blocks.to_vec(), blobs.to_vec(), + da_checker, spec, )) } @@ -248,6 +260,8 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { column_to_peer_id, expected_custody_columns, *attempt, + da_checker, + spec, ); if let Err(CouplingError::DataColumnPeerFailure { @@ -269,11 +283,15 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { } } - fn responses_with_blobs( + fn responses_with_blobs<T>( blocks: Vec<Arc<SignedBeaconBlock<E>>>, blobs: Vec<Arc<BlobSidecar<E>>>, - spec: &ChainSpec, - ) -> Result<Vec<RpcBlock<E>>, CouplingError> { + da_checker: Arc<DataAvailabilityChecker<T>>, + spec: Arc<ChainSpec>, + ) -> Result<Vec<RangeSyncBlock<E>>, CouplingError> + where + T: BeaconChainTypes<EthSpec = E>, + { // There can't be more more blobs than blocks. i.e. sending any blob (empty // included) for a skipped slot is not permitted. let mut responses = Vec::with_capacity(blocks.len()); @@ -315,8 +333,9 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { .map_err(|_| { CouplingError::BlobPeerFailure("Blobs returned exceeds max length".to_string()) })?; + let block_data = AvailableBlockData::new_with_blobs(blobs); responses.push( - RpcBlock::new(None, block, Some(blobs)) + RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) .map_err(|e| CouplingError::BlobPeerFailure(format!("{e:?}")))?, ) } @@ -333,20 +352,25 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { Ok(responses) } - fn responses_with_custody_columns( + fn responses_with_custody_columns<T>( blocks: Vec<Arc<SignedBeaconBlock<E>>>, data_columns: DataColumnSidecarList<E>, column_to_peer: HashMap<u64, PeerId>, expects_custody_columns: &[ColumnIndex], attempt: usize, - ) -> Result<Vec<RpcBlock<E>>, CouplingError> { + da_checker: Arc<DataAvailabilityChecker<T>>, + spec: Arc<ChainSpec>, + ) -> Result<Vec<RangeSyncBlock<E>>, CouplingError> + where + T: BeaconChainTypes<EthSpec = E>, + { // Group data columns by block_root and index let mut data_columns_by_block = HashMap::<Hash256, HashMap<ColumnIndex, Arc<DataColumnSidecar<E>>>>::new(); for column in data_columns { let block_root = column.block_root(); - let index = column.index; + let index = *column.index(); if data_columns_by_block .entry(block_root) .or_default() @@ -357,19 +381,19 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { // we request the data from. // If there are duplicated indices, its likely a peer sending us the same index multiple times. // However we can still proceed even if there are extra columns, just log an error. - tracing::debug!(?block_root, ?index, "Repeated column for block_root"); + debug!(?block_root, ?index, "Repeated column for block_root"); continue; } } // Now iterate all blocks ensuring that the block roots of each block and data column match, // plus we have columns for our custody requirements - let mut rpc_blocks = Vec::with_capacity(blocks.len()); + let mut range_sync_blocks = Vec::with_capacity(blocks.len()); let exceeded_retries = attempt >= MAX_COLUMN_RETRIES; for block in blocks { let block_root = get_block_root(&block); - rpc_blocks.push(if block.num_expected_blobs() > 0 { + range_sync_blocks.push(if block.num_expected_blobs() > 0 { let Some(mut data_columns_by_index) = data_columns_by_block.remove(&block_root) else { let responsible_peers = column_to_peer.iter().map(|c| (*c.0, *c.1)).collect(); @@ -408,18 +432,21 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { if !data_columns_by_index.is_empty() { let remaining_indices = data_columns_by_index.keys().collect::<Vec<_>>(); // log the error but don't return an error, we can still progress with extra columns. - tracing::debug!( + debug!( ?block_root, ?remaining_indices, "Not all columns consumed for block" ); } - RpcBlock::new_with_custody_columns(Some(block_root), block, custody_columns) + let block_data = AvailableBlockData::new_with_data_columns(custody_columns.iter().map(|c| c.as_data_column().clone()).collect::<Vec<_>>()); + + RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? } else { // Block has no data, expects zero columns - RpcBlock::new_without_blobs(Some(block_root), block) + RangeSyncBlock::new(block, AvailableBlockData::NoData, &da_checker, spec.clone()) + .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? }); } @@ -428,10 +455,10 @@ impl<E: EthSpec> RangeBlockComponentsRequest<E> { let remaining_roots = data_columns_by_block.keys().collect::<Vec<_>>(); // log the error but don't return an error, we can still progress with responses. // this is most likely an internal error with overrequesting or a client bug. - tracing::debug!(?remaining_roots, "Not all columns consumed for block"); + debug!(?remaining_roots, "Not all columns consumed for block"); } - Ok(rpc_blocks) + Ok(range_sync_blocks) } } @@ -459,10 +486,13 @@ impl<I: PartialEq + std::fmt::Display, T> ByRangeRequest<I, T> { #[cfg(test)] mod tests { - use super::RangeBlockComponentsRequest; use crate::sync::network_context::MAX_COLUMN_RETRIES; + + use super::RangeBlockComponentsRequest; + use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::test_utils::{ - NumBlobs, generate_rand_block_and_blobs, generate_rand_block_and_data_columns, test_spec, + NumBlobs, generate_rand_block_and_blobs, generate_rand_block_and_data_columns, + test_da_checker, test_spec, }; use lighthouse_network::{ PeerId, @@ -472,7 +502,7 @@ mod tests { }, }; use rand::SeedableRng; - use std::sync::Arc; + use std::{collections::HashMap, sync::Arc}; use tracing::Span; use types::{Epoch, ForkName, MinimalEthSpec as E, SignedBeaconBlock, test_utils::XorShiftRng}; @@ -512,8 +542,9 @@ mod tests { } fn is_finished(info: &mut RangeBlockComponentsRequest<E>) -> bool { - let spec = test_spec::<E>(); - info.responses(&spec).is_some() + let spec = Arc::new(test_spec::<E>()); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + info.responses(da_checker, spec).is_some() } #[test] @@ -534,8 +565,11 @@ mod tests { // Send blocks and complete terminate response info.add_blocks(blocks_req_id, blocks).unwrap(); + let spec = Arc::new(test_spec::<E>()); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + // Assert response is finished and RpcBlocks can be constructed - info.responses(&test_spec::<E>()).unwrap().unwrap(); + info.responses(da_checker, spec).unwrap().unwrap(); } #[test] @@ -565,16 +599,26 @@ mod tests { // Expect no blobs returned info.add_blobs(blobs_req_id, vec![]).unwrap(); - // Assert response is finished and RpcBlocks can be constructed, even if blobs weren't returned. - // This makes sure we don't expect blobs here when they have expired. Checking this logic should - // be hendled elsewhere. - info.responses(&test_spec::<E>()).unwrap().unwrap(); + let mut spec = test_spec::<E>(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + // Assert response is finished and RpcBlocks cannot be constructed, because blobs weren't returned. + let result = info.responses(da_checker, spec).unwrap(); + assert!(result.is_err()) } #[test] fn rpc_block_with_custody_columns() { - let spec = test_spec::<E>(); - let expects_custody_columns = vec![1, 2, 3, 4]; + let mut spec = test_spec::<E>(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let expects_custody_columns = da_checker + .custody_context() + .sampling_columns_for_epoch(Epoch::new(0), &spec) + .to_vec(); let mut rng = XorShiftRng::from_seed([42; 16]); let blocks = (0..4) .map(|_| { @@ -624,7 +668,7 @@ mod tests { *req, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| d.index == column_index).cloned()) + .flat_map(|b| b.1.iter().filter(|d| *d.index() == column_index).cloned()) .collect(), ) .unwrap(); @@ -638,18 +682,26 @@ mod tests { } // All completed construct response - info.responses(&spec).unwrap().unwrap(); + info.responses(da_checker, spec).unwrap().unwrap(); } #[test] fn rpc_block_with_custody_columns_batched() { - let spec = test_spec::<E>(); - let batched_column_requests = [vec![1_u64, 2], vec![3, 4]]; - let expects_custody_columns = batched_column_requests - .iter() - .flatten() - .cloned() - .collect::<Vec<_>>(); + let mut spec = test_spec::<E>(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let expected_sampling_columns = da_checker + .custody_context() + .sampling_columns_for_epoch(Epoch::new(0), &spec) + .to_vec(); + // Split sampling columns into two batches + let mid = expected_sampling_columns.len() / 2; + let batched_column_requests = [ + expected_sampling_columns[..mid].to_vec(), + expected_sampling_columns[mid..].to_vec(), + ]; let custody_column_request_ids = (0..batched_column_requests.len() as u32).collect::<Vec<_>>(); let num_of_data_column_requests = custody_column_request_ids.len(); @@ -673,7 +725,7 @@ mod tests { let mut info = RangeBlockComponentsRequest::<E>::new( blocks_req_id, None, - Some((columns_req_id.clone(), expects_custody_columns.clone())), + Some((columns_req_id.clone(), expected_sampling_columns.clone())), Span::none(), ); @@ -707,7 +759,7 @@ mod tests { .iter() .flat_map(|b| { b.1.iter() - .filter(|d| column_indices.contains(&d.index)) + .filter(|d| column_indices.contains(d.index())) .cloned() }) .collect::<Vec<_>>(), @@ -723,14 +775,18 @@ mod tests { } // All completed construct response - info.responses(&spec).unwrap().unwrap(); + info.responses(da_checker, spec).unwrap().unwrap(); } #[test] fn missing_custody_columns_from_faulty_peers() { - // GIVEN: A request expecting custody columns from multiple peers - let spec = test_spec::<E>(); - let expected_custody_columns = vec![1, 2, 3, 4]; + // GIVEN: A request expecting sampling columns from multiple peers + let spec = Arc::new(test_spec::<E>()); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let expected_sampling_columns = da_checker + .custody_context() + .sampling_columns_for_epoch(Epoch::new(0), &spec) + .to_vec(); let mut rng = XorShiftRng::from_seed([42; 16]); let blocks = (0..2) .map(|_| { @@ -745,7 +801,7 @@ mod tests { let components_id = components_id(); let blocks_req_id = blocks_id(components_id); - let columns_req_id = expected_custody_columns + let columns_req_id = expected_sampling_columns .iter() .enumerate() .map(|(i, column)| { @@ -761,7 +817,7 @@ mod tests { let mut info = RangeBlockComponentsRequest::<E>::new( blocks_req_id, None, - Some((columns_req_id.clone(), expected_custody_columns.clone())), + Some((columns_req_id.clone(), expected_sampling_columns.clone())), Span::none(), ); @@ -772,27 +828,27 @@ mod tests { ) .unwrap(); - // AND: Only some custody columns are received (columns 1 and 2) - for (i, &column_index) in expected_custody_columns.iter().take(2).enumerate() { + // AND: Only the first 2 sampling columns are received successfully + for (i, &column_index) in expected_sampling_columns.iter().take(2).enumerate() { let (req, _columns) = columns_req_id.get(i).unwrap(); info.add_custody_columns( *req, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| d.index == column_index).cloned()) + .flat_map(|b| b.1.iter().filter(|d| *d.index() == column_index).cloned()) .collect(), ) .unwrap(); } // AND: Remaining column requests are completed with empty data (simulating faulty peers) - for i in 2..4 { + for i in 2..expected_sampling_columns.len() { let (req, _columns) = columns_req_id.get(i).unwrap(); info.add_custody_columns(*req, vec![]).unwrap(); } // WHEN: Attempting to construct RPC blocks - let result = info.responses(&spec).unwrap(); + let result = info.responses(da_checker, spec).unwrap(); // THEN: Should fail with PeerFailure identifying the faulty peers assert!(result.is_err()); @@ -803,9 +859,13 @@ mod tests { }) = result { assert!(error.contains("Peers did not return column")); - assert_eq!(faulty_peers.len(), 2); // columns 3 and 4 missing - assert_eq!(faulty_peers[0].0, 3); // column index 3 - assert_eq!(faulty_peers[1].0, 4); // column index 4 + // All columns after the first 2 should be reported as faulty + let expected_faulty_count = expected_sampling_columns.len() - 2; + assert_eq!(faulty_peers.len(), expected_faulty_count); + // Verify the faulty column indices match + for (i, (column_index, _peer)) in faulty_peers.iter().enumerate() { + assert_eq!(*column_index, expected_sampling_columns[i + 2]); + } assert!(!exceeded_retries); // First attempt, should be false } else { panic!("Expected PeerFailure error"); @@ -814,9 +874,16 @@ mod tests { #[test] fn retry_logic_after_peer_failures() { - // GIVEN: A request expecting custody columns where some peers initially fail - let spec = test_spec::<E>(); - let expected_custody_columns = vec![1, 2]; + // GIVEN: A request expecting sampling columns where some peers initially fail + let mut spec = test_spec::<E>(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let expected_sampling_columns = da_checker + .custody_context() + .sampling_columns_for_epoch(Epoch::new(0), &spec) + .to_vec(); let mut rng = XorShiftRng::from_seed([42; 16]); let blocks = (0..2) .map(|_| { @@ -831,7 +898,7 @@ mod tests { let components_id = components_id(); let blocks_req_id = blocks_id(components_id); - let columns_req_id = expected_custody_columns + let columns_req_id = expected_sampling_columns .iter() .enumerate() .map(|(i, column)| { @@ -847,7 +914,7 @@ mod tests { let mut info = RangeBlockComponentsRequest::<E>::new( blocks_req_id, None, - Some((columns_req_id.clone(), expected_custody_columns.clone())), + Some((columns_req_id.clone(), expected_sampling_columns.clone())), Span::none(), ); @@ -858,58 +925,80 @@ mod tests { ) .unwrap(); - // AND: Only partial custody columns are received (column 1 but not 2) - let (req1, _) = columns_req_id.first().unwrap(); + // AND: Only partial sampling columns are received (first column but not others) + let (req0, _) = columns_req_id.first().unwrap(); info.add_custody_columns( - *req1, + *req0, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| d.index == 1).cloned()) + .flat_map(|b| { + b.1.iter() + .filter(|d| *d.index() == expected_sampling_columns[0]) + .cloned() + }) .collect(), ) .unwrap(); - // AND: The missing column request is completed with empty data (peer failure) - let (req2, _) = columns_req_id.get(1).unwrap(); - info.add_custody_columns(*req2, vec![]).unwrap(); + // AND: The remaining column requests are completed with empty data (peer failure) + for i in 1..expected_sampling_columns.len() { + let (req, _) = columns_req_id.get(i).unwrap(); + info.add_custody_columns(*req, vec![]).unwrap(); + } - // WHEN: First attempt to get responses fails - let result = info.responses(&spec).unwrap(); + let result: Result< + Vec<beacon_chain::block_verification_types::RangeSyncBlock<E>>, + crate::sync::block_sidecar_coupling::CouplingError, + > = info.responses(da_checker.clone(), spec.clone()).unwrap(); assert!(result.is_err()); - // AND: We retry with a new peer for the failed column + // AND: We retry with a new peer for the failed columns let new_columns_req_id = columns_id( 10 as Id, DataColumnsByRangeRequester::ComponentsByRange(components_id), ); - let failed_column_requests = vec![(new_columns_req_id, vec![2])]; - info.reinsert_failed_column_requests(failed_column_requests) - .unwrap(); + for column in &expected_sampling_columns[1..] { + let failed_column_requests = vec![(new_columns_req_id, vec![*column])]; + info.reinsert_failed_column_requests(failed_column_requests) + .unwrap(); + } // AND: The new peer provides the missing column data + let failed_column_indices: Vec<_> = expected_sampling_columns[1..].to_vec(); info.add_custody_columns( new_columns_req_id, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| d.index == 2).cloned()) + .flat_map(|b| { + b.1.iter() + .filter(|d| failed_column_indices.contains(d.index())) + .cloned() + }) .collect(), ) .unwrap(); // WHEN: Attempting to get responses again - let result = info.responses(&spec).unwrap(); + let result = info.responses(da_checker, spec).unwrap(); - // THEN: Should succeed with complete RPC blocks + // THEN: Should succeed with complete RangeSync blocks assert!(result.is_ok()); - let rpc_blocks = result.unwrap(); - assert_eq!(rpc_blocks.len(), 2); + let range_sync_blocks = result.unwrap(); + assert_eq!(range_sync_blocks.len(), 2); } #[test] fn max_retries_exceeded_behavior() { // GIVEN: A request where peers consistently fail to provide required columns - let spec = test_spec::<E>(); - let expected_custody_columns = vec![1, 2]; + let mut spec = test_spec::<E>(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let expected_sampling_columns = da_checker + .custody_context() + .sampling_columns_for_epoch(Epoch::new(0), &spec) + .to_vec(); let mut rng = XorShiftRng::from_seed([42; 16]); let blocks = (0..1) .map(|_| { @@ -924,7 +1013,7 @@ mod tests { let components_id = components_id(); let blocks_req_id = blocks_id(components_id); - let columns_req_id = expected_custody_columns + let columns_req_id = expected_sampling_columns .iter() .enumerate() .map(|(i, column)| { @@ -940,7 +1029,7 @@ mod tests { let mut info = RangeBlockComponentsRequest::<E>::new( blocks_req_id, None, - Some((columns_req_id.clone(), expected_custody_columns.clone())), + Some((columns_req_id.clone(), expected_sampling_columns.clone())), Span::none(), ); @@ -951,24 +1040,30 @@ mod tests { ) .unwrap(); - // AND: Only partial custody columns are provided (column 1 but not 2) - let (req1, _) = columns_req_id.first().unwrap(); + // AND: Only the first sampling column is provided successfully + let (req0, _) = columns_req_id.first().unwrap(); info.add_custody_columns( - *req1, + *req0, blocks .iter() - .flat_map(|b| b.1.iter().filter(|d| d.index == 1).cloned()) + .flat_map(|b| { + b.1.iter() + .filter(|d| *d.index() == expected_sampling_columns[0]) + .cloned() + }) .collect(), ) .unwrap(); - // AND: Column 2 request completes with empty data (persistent peer failure) - let (req2, _) = columns_req_id.get(1).unwrap(); - info.add_custody_columns(*req2, vec![]).unwrap(); + // AND: All other column requests complete with empty data (persistent peer failure) + for i in 1..expected_sampling_columns.len() { + let (req, _) = columns_req_id.get(i).unwrap(); + info.add_custody_columns(*req, vec![]).unwrap(); + } // WHEN: Multiple retry attempts are made (up to max retries) for _ in 0..MAX_COLUMN_RETRIES { - let result = info.responses(&spec).unwrap(); + let result = info.responses(da_checker.clone(), spec.clone()).unwrap(); assert!(result.is_err()); if let Err(super::CouplingError::DataColumnPeerFailure { @@ -981,7 +1076,7 @@ mod tests { } // AND: One final attempt after exceeding max retries - let result = info.responses(&spec).unwrap(); + let result = info.responses(da_checker, spec).unwrap(); // THEN: Should fail with exceeded_retries = true assert!(result.is_err()); @@ -991,8 +1086,16 @@ mod tests { exceeded_retries, }) = result { - assert_eq!(faulty_peers.len(), 1); // column 2 missing - assert_eq!(faulty_peers[0].0, 2); // column index 2 + // All columns except the first one should be faulty + let expected_faulty_count = expected_sampling_columns.len() - 1; + assert_eq!(faulty_peers.len(), expected_faulty_count); + + let mut faulty_peers = faulty_peers.into_iter().collect::<HashMap<u64, PeerId>>(); + // Only the columns that failed (indices 1..N) should be in faulty_peers + for column in &expected_sampling_columns[1..] { + faulty_peers.remove(column); + } + assert!(faulty_peers.is_empty()); assert!(exceeded_retries); // Should be true after max retries } else { panic!("Expected PeerFailure error with exceeded_retries=true"); diff --git a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs index bb2c6799f1..fe4c7dfe4c 100644 --- a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs @@ -10,17 +10,18 @@ use lighthouse_network::{ service::api_types::{CustodyBackFillBatchRequestId, CustodyBackfillBatchId}, types::CustodyBackFillState, }; -use lighthouse_tracing::SPAN_CUSTODY_BACKFILL_SYNC_BATCH_REQUEST; use logging::crit; use std::hash::{DefaultHasher, Hash, Hasher}; +use strum::IntoEnumIterator; use tracing::{debug, error, info, info_span, warn}; use types::{DataColumnSidecarList, Epoch, EthSpec}; +use crate::metrics; use crate::sync::{ backfill_sync::{BACKFILL_EPOCHS_PER_BATCH, ProcessResult, SyncStart}, batch::{ - BatchConfig, BatchId, BatchInfo, BatchOperationOutcome, BatchProcessingResult, BatchState, - ByRangeRequestType, + BatchConfig, BatchId, BatchInfo, BatchMetricsState, BatchOperationOutcome, + BatchProcessingResult, BatchState, ByRangeRequestType, }, block_sidecar_coupling::CouplingError, manager::CustodyBatchProcessResult, @@ -423,7 +424,7 @@ impl<T: BeaconChainTypes> CustodyBackFillSync<T> { .iter() .filter(|&(_epoch, batch)| in_buffer(batch)) .count() - > BACKFILL_BATCH_BUFFER_SIZE as usize + >= BACKFILL_BATCH_BUFFER_SIZE as usize { return None; } @@ -1004,7 +1005,7 @@ impl<T: BeaconChainTypes> CustodyBackFillSync<T> { network: &mut SyncNetworkContext<T>, batch_id: BatchId, ) -> Result<(), CustodyBackfillError> { - let span = info_span!(SPAN_CUSTODY_BACKFILL_SYNC_BATCH_REQUEST); + let span = info_span!("lh_custody_backfill_sync_batch_request"); let _enter = span.enter(); if let Some(batch) = self.batches.get_mut(&batch_id) { @@ -1115,6 +1116,21 @@ impl<T: BeaconChainTypes> CustodyBackFillSync<T> { *self.network_globals.custody_sync_state.write() = state; } + pub fn register_metrics(&self) { + for state in BatchMetricsState::iter() { + let count = self + .batches + .values() + .filter(|b| b.state().metrics_state() == state) + .count(); + metrics::set_gauge_vec( + &metrics::SYNCING_CHAIN_BATCHES, + &["custody_backfill", state.into()], + count as i64, + ); + } + } + /// A fully synced peer has joined us. /// If we are in a failed state, update a local variable to indicate we are able to restart /// the failed sync on the next attempt. diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 338f21ce98..734295ac1d 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -49,7 +49,6 @@ use crate::sync::block_lookups::{ use crate::sync::custody_backfill_sync::CustodyBackFillSync; use crate::sync::network_context::{PeerGroup, RpcResponseResult}; use beacon_chain::block_verification_types::AsBlock; -use beacon_chain::validator_monitor::timestamp_now; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes, BlockError, EngineState, }; @@ -70,6 +69,7 @@ use slot_clock::SlotClock; use std::ops::Sub; use std::sync::Arc; use std::time::Duration; +use strum::IntoStaticStr; use tokio::sync::mpsc; use tracing::{debug, error, info, trace}; use types::{ @@ -90,7 +90,7 @@ pub const SLOT_IMPORT_TOLERANCE: usize = 32; /// arbitrary number that covers a full slot, but allows recovery if sync get stuck for a few slots. const NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS: u64 = 30; -#[derive(Debug)] +#[derive(Debug, IntoStaticStr)] /// A message that can be sent to the sync manager thread. pub enum SyncMessage<E: EthSpec> { /// A useful peer has been discovered. @@ -141,6 +141,14 @@ pub enum SyncMessage<E: EthSpec> { /// A data column with an unknown parent has been received. UnknownParentDataColumn(PeerId, Arc<DataColumnSidecar<E>>), + /// A partial data column with an unknown parent has been received. + UnknownParentPartialDataColumn { + peer_id: PeerId, + block_root: Hash256, + parent_root: Hash256, + slot: Slot, + }, + /// A peer has sent an attestation that references a block that is unknown. This triggers the /// manager to attempt to find the block matching the unknown hash. UnknownBlockHashFromAttestation(PeerId, Hash256), @@ -323,17 +331,18 @@ impl<T: BeaconChainTypes> SyncManager<T> { } #[cfg(test)] - pub(crate) fn active_single_lookups(&self) -> Vec<super::block_lookups::BlockLookupSummary> { - self.block_lookups.active_single_lookups() + pub(crate) fn send_sync_message(&mut self, sync_message: SyncMessage<<T>::EthSpec>) { + self.network.send_sync_message(sync_message); } #[cfg(test)] - pub(crate) fn active_parent_lookups(&self) -> Vec<Vec<Hash256>> { - self.block_lookups - .active_parent_lookups() - .iter() - .map(|c| c.chain.clone()) - .collect() + pub(crate) fn block_lookups(&self) -> &BlockLookups<T> { + &self.block_lookups + } + + #[cfg(test)] + pub(crate) fn range_sync(&self) -> &RangeSync<T> { + &self.range_sync } #[cfg(test)] @@ -512,17 +521,18 @@ impl<T: BeaconChainTypes> SyncManager<T> { /// there is no way to guarantee that libp2p always emits a error along with /// the disconnect. fn peer_disconnect(&mut self, peer_id: &PeerId) { - // Inject a Disconnected error on all requests associated with the disconnected peer - // to retry all batches/lookups - for sync_request_id in self.network.peer_disconnected(peer_id) { - self.inject_error(*peer_id, sync_request_id, RPCError::Disconnected); - } - // Remove peer from all data structures self.range_sync.peer_disconnect(&mut self.network, peer_id); let _ = self.backfill_sync.peer_disconnected(peer_id); self.block_lookups.peer_disconnected(peer_id); + // Inject a Disconnected error on all requests associated with the disconnected peer + // to retry all batches/lookups. Only after removing the peer from the data structures to + // avoid sending retry requests to the disconnecting peer. + for sync_request_id in self.network.peer_disconnected(peer_id) { + self.inject_error(*peer_id, sync_request_id, RPCError::Disconnected); + } + // Regardless of the outcome, we update the sync status. self.update_sync_state(); } @@ -784,6 +794,9 @@ impl<T: BeaconChainTypes> SyncManager<T> { } _ = register_metrics_interval.tick() => { self.network.register_metrics(); + self.range_sync.register_metrics(); + self.backfill_sync.register_metrics(); + self.custody_backfill_sync.register_metrics(); } _ = epoch_interval.tick() => { self.update_sync_state(); @@ -845,7 +858,7 @@ impl<T: BeaconChainTypes> SyncManager<T> { BlockComponent::Block(DownloadResult { value: block.block_cloned(), block_root, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); @@ -861,9 +874,9 @@ impl<T: BeaconChainTypes> SyncManager<T> { parent_root, blob_slot, BlockComponent::Blob(DownloadResult { - value: blob, + value: parent_root, block_root, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); @@ -871,17 +884,49 @@ impl<T: BeaconChainTypes> SyncManager<T> { SyncMessage::UnknownParentDataColumn(peer_id, data_column) => { let data_column_slot = data_column.slot(); let block_root = data_column.block_root(); - let parent_root = data_column.block_parent_root(); - debug!(%block_root, %parent_root, "Received unknown parent data column message"); + match data_column.as_ref() { + DataColumnSidecar::Fulu(column) => { + let parent_root = column.block_parent_root(); + debug!(%block_root, %parent_root, "Received unknown parent data column message"); + self.handle_unknown_parent( + peer_id, + block_root, + parent_root, + data_column_slot, + BlockComponent::DataColumn(DownloadResult { + value: parent_root, + block_root, + seen_timestamp: self + .chain + .slot_clock + .now_duration() + .unwrap_or_default(), + peer_group: PeerGroup::from_single(peer_id), + }), + ); + } + // TODO(gloas) support gloas data column variant + DataColumnSidecar::Gloas(_) => { + error!("Gloas variant not yet supported") + } + } + } + SyncMessage::UnknownParentPartialDataColumn { + peer_id, + block_root, + parent_root, + slot, + } => { + debug!(%block_root, %parent_root, "Received unknown parent partial column message"); self.handle_unknown_parent( peer_id, block_root, parent_root, - data_column_slot, - BlockComponent::DataColumn(DownloadResult { - value: data_column, + slot, + BlockComponent::PartialDataColumn(DownloadResult { + value: parent_root, block_root, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 2e0c56db23..b1ba87c75d 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -17,7 +17,8 @@ use crate::sync::block_lookups::SingleLookupId; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::network_context::requests::BlobsByRootSingleBlockRequest; use crate::sync::range_data_column_batch_request::RangeDataColumnBatchRequest; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::block_verification_types::{AsBlock, RangeSyncBlock}; use beacon_chain::{BeaconChain, BeaconChainTypes, BlockProcessStatus, EngineState}; use custody::CustodyRequestResult; use fnv::FnvHashMap; @@ -31,7 +32,6 @@ use lighthouse_network::service::api_types::{ DataColumnsByRootRequester, Id, SingleLookupReqId, SyncRequestId, }; use lighthouse_network::{Client, NetworkGlobals, PeerAction, PeerId, ReportSource}; -use lighthouse_tracing::{SPAN_OUTGOING_BLOCK_BY_ROOT_REQUEST, SPAN_OUTGOING_RANGE_REQUEST}; use parking_lot::RwLock; pub use requests::LookupVerifyError; use requests::{ @@ -49,7 +49,7 @@ use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Span, debug, debug_span, error, warn}; -use types::blob_sidecar::FixedBlobSidecarList; +use types::data::FixedBlobSidecarList; use types::{ BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, EthSpec, ForkContext, Hash256, SignedBeaconBlock, Slot, @@ -546,7 +546,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { ) -> Result<Id, RpcRequestSendError> { let range_request_span = debug_span!( parent: None, - SPAN_OUTGOING_RANGE_REQUEST, + "lh_outgoing_range_request", range_req_id = %requester, block_peers = block_peers.len(), column_peers = column_peers.len() @@ -736,7 +736,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { &mut self, id: ComponentsByRangeRequestId, range_block_component: RangeBlockComponent<T::EthSpec>, - ) -> Option<Result<Vec<RpcBlock<T::EthSpec>>, RpcResponseError>> { + ) -> Option<Result<Vec<RangeSyncBlock<T::EthSpec>>, RpcResponseError>> { let Entry::Occupied(mut entry) = self.components_by_range_requests.entry(id) else { metrics::inc_counter_vec(&metrics::SYNC_UNKNOWN_NETWORK_REQUESTS, &["range_blocks"]); return None; @@ -777,7 +777,10 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { } let range_req = entry.get_mut(); - if let Some(blocks_result) = range_req.responses(&self.chain.spec) { + if let Some(blocks_result) = range_req.responses( + self.chain.data_availability_checker.clone(), + self.chain.spec.clone(), + ) { if let Err(CouplingError::DataColumnPeerFailure { error, faulty_peers: _, @@ -908,7 +911,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { let request_span = debug_span!( parent: Span::current(), - SPAN_OUTGOING_BLOCK_BY_ROOT_REQUEST, + "lh_outgoing_block_by_root_request", %block_root, ); self.blocks_by_root_requests.insert( @@ -1093,13 +1096,14 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { })?; // Include only the blob indexes not yet imported (received through gossip) - let custody_indexes_to_fetch = self + let mut custody_indexes_to_fetch = self .chain .sampling_columns_for_epoch(current_epoch) .iter() .copied() .filter(|index| !custody_indexes_imported.contains(index)) .collect::<Vec<_>>(); + custody_indexes_to_fetch.sort_unstable(); if custody_indexes_to_fetch.is_empty() { // No indexes required, do not issue any request @@ -1428,7 +1432,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { } }) }); - self.on_rpc_response_result(id, "BlocksByRoot", resp, peer_id, |_| 1) + self.on_rpc_response_result(resp, peer_id) } pub(crate) fn on_single_blob_response( @@ -1457,7 +1461,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { } }) }); - self.on_rpc_response_result(id, "BlobsByRoot", resp, peer_id, |_| 1) + self.on_rpc_response_result(resp, peer_id) } #[allow(clippy::type_complexity)] @@ -1470,7 +1474,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { let resp = self .data_columns_by_root_requests .on_response(id, rpc_event); - self.on_rpc_response_result(id, "DataColumnsByRoot", resp, peer_id, |_| 1) + self.on_rpc_response_result(resp, peer_id) } #[allow(clippy::type_complexity)] @@ -1481,7 +1485,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { rpc_event: RpcEvent<Arc<SignedBeaconBlock<T::EthSpec>>>, ) -> Option<RpcResponseResult<Vec<Arc<SignedBeaconBlock<T::EthSpec>>>>> { let resp = self.blocks_by_range_requests.on_response(id, rpc_event); - self.on_rpc_response_result(id, "BlocksByRange", resp, peer_id, |b| b.len()) + self.on_rpc_response_result(resp, peer_id) } #[allow(clippy::type_complexity)] @@ -1492,7 +1496,7 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { rpc_event: RpcEvent<Arc<BlobSidecar<T::EthSpec>>>, ) -> Option<RpcResponseResult<Vec<Arc<BlobSidecar<T::EthSpec>>>>> { let resp = self.blobs_by_range_requests.on_response(id, rpc_event); - self.on_rpc_response_result(id, "BlobsByRangeRequest", resp, peer_id, |b| b.len()) + self.on_rpc_response_result(resp, peer_id) } #[allow(clippy::type_complexity)] @@ -1505,36 +1509,15 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { let resp = self .data_columns_by_range_requests .on_response(id, rpc_event); - self.on_rpc_response_result(id, "DataColumnsByRange", resp, peer_id, |d| d.len()) + self.on_rpc_response_result(resp, peer_id) } - fn on_rpc_response_result<I: std::fmt::Display, R, F: FnOnce(&R) -> usize>( + /// Common handler for consistent scoring of RpcResponseError + fn on_rpc_response_result<R>( &mut self, - id: I, - method: &'static str, resp: Option<RpcResponseResult<R>>, peer_id: PeerId, - get_count: F, ) -> Option<RpcResponseResult<R>> { - match &resp { - None => {} - Some(Ok((v, _))) => { - debug!( - %id, - method, - count = get_count(v), - "Sync RPC request completed" - ); - } - Some(Err(e)) => { - debug!( - %id, - method, - error = ?e, - "Sync RPC request error" - ); - } - } if let Some(Err(RpcResponseError::VerifyError(e))) = &resp { self.report_peer(peer_id, PeerAction::LowToleranceError, e.into()); } @@ -1606,15 +1589,15 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { .beacon_processor_if_enabled() .ok_or(SendErrorProcessor::ProcessorNotAvailable)?; - let block = RpcBlock::new_without_blobs(Some(block_root), block); + let lookup_block = LookupBlock::new(block); - debug!(block = ?block_root, id, "Sending block for processing"); + debug!(block = ?block_root, block_slot = %lookup_block.slot(), id, "Sending block for processing"); // Lookup sync event safety: If `beacon_processor.send_rpc_beacon_block` returns Ok() sync // must receive a single `SyncMessage::BlockComponentProcessed` with this process type beacon_processor - .send_rpc_beacon_block( + .send_lookup_beacon_block( block_root, - block, + lookup_block, seen_timestamp, BlockProcessType::SingleBlock { id }, ) @@ -1719,8 +1702,8 @@ impl<T: BeaconChainTypes> SyncNetworkContext<T> { }; let result = columns_by_range_peers_to_request - .iter() - .filter_map(|(peer_id, _)| { + .keys() + .filter_map(|peer_id| { self.send_data_columns_by_range_request( *peer_id, request.clone(), diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index 71e002cc42..620962b40b 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -2,18 +2,17 @@ use crate::sync::network_context::{ DataColumnsByRootRequestId, DataColumnsByRootSingleBlockRequest, }; use beacon_chain::BeaconChainTypes; -use beacon_chain::validator_monitor::timestamp_now; use fnv::FnvHashMap; use lighthouse_network::PeerId; use lighthouse_network::service::api_types::{CustodyId, DataColumnsByRootRequester}; -use lighthouse_tracing::SPAN_OUTGOING_CUSTODY_REQUEST; use parking_lot::RwLock; +use slot_clock::SlotClock; use std::collections::HashSet; use std::hash::{BuildHasher, RandomState}; use std::time::{Duration, Instant}; use std::{collections::HashMap, marker::PhantomData, sync::Arc}; use tracing::{Span, debug, debug_span, warn}; -use types::{DataColumnSidecar, Hash256, data_column_sidecar::ColumnIndex}; +use types::{DataColumnSidecar, Hash256, data::ColumnIndex}; use types::{DataColumnSidecarList, EthSpec}; use super::{LookupRequestResult, PeerGroup, RpcResponseResult, SyncNetworkContext}; @@ -69,7 +68,7 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { ) -> Self { let span = debug_span!( parent: Span::current(), - SPAN_OUTGOING_CUSTODY_REQUEST, + "lh_outgoing_custody_request", %block_root, ); Self { @@ -128,7 +127,7 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { // requested index. The worse case is 128 loops over a 128 item vec + mutation to // drop the consumed columns. let mut data_columns = HashMap::<ColumnIndex, _>::from_iter( - data_columns.into_iter().map(|d| (d.index, d)), + data_columns.into_iter().map(|d| (*d.index(), d)), ); // Accumulate columns that the peer does not have to issue a single log per request let mut missing_column_indexes = vec![]; @@ -199,7 +198,14 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { cx: &mut SyncNetworkContext<T>, ) -> CustodyRequestResult<T::EthSpec> { let _guard = self.span.clone().entered(); - if self.column_requests.values().all(|r| r.is_downloaded()) { + let total_requests = self.column_requests.len(); + let completed_requests = self + .column_requests + .values() + .filter(|r| r.is_downloaded()) + .count(); + + if completed_requests >= total_requests { // All requests have completed successfully. let mut peers = HashMap::<PeerId, Vec<usize>>::new(); let mut seen_timestamps = vec![]; @@ -210,19 +216,23 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { peers .entry(peer) .or_default() - .push(data_column.index as usize); + .push(*data_column.index() as usize); seen_timestamps.push(seen_timestamp); Ok(data_column) }) .collect::<Result<Vec<_>, _>>()?; let peer_group = PeerGroup::from_set(peers); - let max_seen_timestamp = seen_timestamps.into_iter().max().unwrap_or(timestamp_now()); + let max_seen_timestamp = seen_timestamps + .into_iter() + .max() + .unwrap_or_else(|| cx.chain.slot_clock.now_duration().unwrap_or_default()); return Ok(Some((columns, peer_group, max_seen_timestamp))); } let active_request_count_by_peer = cx.active_request_count_by_peer(); let mut columns_to_request_by_peer = HashMap::<PeerId, Vec<ColumnIndex>>::new(); + let mut columns_without_peers = vec![]; let lookup_peers = self.lookup_peers.read(); // Create deterministic hasher per request to ensure consistent peer ordering within // this request (avoiding fragmentation) while varying selection across different requests @@ -232,7 +242,7 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { if let Some(wait_duration) = request.is_awaiting_download() { // Note: an empty response is considered a successful response, so we may end up // retrying many more times than `MAX_CUSTODY_COLUMN_DOWNLOAD_ATTEMPTS`. - if request.download_failures > MAX_CUSTODY_COLUMN_DOWNLOAD_ATTEMPTS { + if request.download_failures >= MAX_CUSTODY_COLUMN_DOWNLOAD_ATTEMPTS { return Err(Error::TooManyFailures); } @@ -257,6 +267,7 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { return Err(Error::NoPeer(*column_index)); } else { // Do not issue requests if there is no custody peer on this column + columns_without_peers.push(*column_index); } } } @@ -271,10 +282,13 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { lookup_peers = lookup_peers.len(), "Requesting {} columns from {} peers", columns_requested_count, peer_requests, ); - } else { + } else if !columns_without_peers.is_empty() { debug!( lookup_peers = lookup_peers.len(), - "No column peers found for look up", + total_requests, + completed_requests, + ?columns_without_peers, + "No column peers found for lookup", ); } @@ -289,7 +303,7 @@ impl<T: BeaconChainTypes> ActiveCustodyRequest<T> { }, // If peer is in the lookup peer set, it claims to have imported the block and // must have its columns in custody. In that case, set `true = enforce max_requests` - // and downscore if data_columns_by_root does not returned the expected custody + // and downscore if data_columns_by_root does not return the expected custody // columns. For the rest of peers, don't downscore if columns are missing. lookup_peers.contains(&peer_id), ) diff --git a/beacon_node/network/src/sync/network_context/requests.rs b/beacon_node/network/src/sync/network_context/requests.rs index 3183c06d76..ad60dffb45 100644 --- a/beacon_node/network/src/sync/network_context/requests.rs +++ b/beacon_node/network/src/sync/network_context/requests.rs @@ -1,10 +1,11 @@ +use std::time::Instant; use std::{collections::hash_map::Entry, hash::Hash}; -use beacon_chain::validator_monitor::timestamp_now; use fnv::FnvHashMap; use lighthouse_network::PeerId; +use slot_clock::timestamp_now; use strum::IntoStaticStr; -use tracing::Span; +use tracing::{Span, debug}; use types::{Hash256, Slot}; pub use blobs_by_range::BlobsByRangeRequestItems; @@ -18,7 +19,7 @@ pub use data_columns_by_root::{ use crate::metrics; -use super::{RpcEvent, RpcResponseResult}; +use super::{RpcEvent, RpcResponseError, RpcResponseResult}; mod blobs_by_range; mod blobs_by_root; @@ -51,6 +52,7 @@ struct ActiveRequest<T: ActiveRequestItems> { peer_id: PeerId, // Error if the request terminates before receiving max expected responses expect_max_responses: bool, + start_instant: Instant, span: Span, } @@ -60,7 +62,7 @@ enum State<T> { Errored, } -impl<K: Eq + Hash, T: ActiveRequestItems> ActiveRequests<K, T> { +impl<K: Copy + Eq + Hash + std::fmt::Display, T: ActiveRequestItems> ActiveRequests<K, T> { pub fn new(name: &'static str) -> Self { Self { requests: <_>::default(), @@ -83,6 +85,7 @@ impl<K: Eq + Hash, T: ActiveRequestItems> ActiveRequests<K, T> { state: State::Active(items), peer_id, expect_max_responses, + start_instant: Instant::now(), span, }, ); @@ -112,7 +115,7 @@ impl<K: Eq + Hash, T: ActiveRequestItems> ActiveRequests<K, T> { return None; }; - match rpc_event { + let result = match rpc_event { // Handler of a success ReqResp chunk. Adds the item to the request accumulator. // `ActiveRequestItems` validates the item before appending to its internal state. RpcEvent::Response(item, seen_timestamp) => { @@ -126,7 +129,7 @@ impl<K: Eq + Hash, T: ActiveRequestItems> ActiveRequests<K, T> { Ok(true) => { let items = items.consume(); request.state = State::CompletedEarly; - Some(Ok((items, seen_timestamp))) + Some(Ok((items, seen_timestamp, request.start_instant.elapsed()))) } // Received item, but we are still expecting more Ok(false) => None, @@ -163,7 +166,11 @@ impl<K: Eq + Hash, T: ActiveRequestItems> ActiveRequests<K, T> { } .into())) } else { - Some(Ok((items.consume(), timestamp_now()))) + Some(Ok(( + items.consume(), + timestamp_now(), + request.start_instant.elapsed(), + ))) } } // Items already returned, ignore stream termination @@ -188,7 +195,41 @@ impl<K: Eq + Hash, T: ActiveRequestItems> ActiveRequests<K, T> { State::Errored => None, } } - } + }; + + result.map(|result| match result { + Ok((items, seen_timestamp, duration)) => { + metrics::inc_counter_vec(&metrics::SYNC_RPC_REQUEST_SUCCESSES, &[self.name]); + metrics::observe_timer_vec(&metrics::SYNC_RPC_REQUEST_TIME, &[self.name], duration); + debug!( + %id, + method = self.name, + count = items.len(), + "Sync RPC request completed" + ); + + Ok((items, seen_timestamp)) + } + Err(e) => { + let err_str: &'static str = match &e { + RpcResponseError::RpcError(e) => e.into(), + RpcResponseError::VerifyError(e) => e.into(), + RpcResponseError::CustodyRequestError(_) => "CustodyRequestError", + RpcResponseError::BlockComponentCouplingError(_) => { + "BlockComponentCouplingError" + } + }; + metrics::inc_counter_vec(&metrics::SYNC_RPC_REQUEST_ERRORS, &[self.name, err_str]); + debug!( + %id, + method = self.name, + error = ?e, + "Sync RPC request error" + ); + + Err(e) + } + }) } pub fn active_requests_of_peer(&self, peer_id: &PeerId) -> Vec<&K> { diff --git a/beacon_node/network/src/sync/network_context/requests/blobs_by_root.rs b/beacon_node/network/src/sync/network_context/requests/blobs_by_root.rs index 39886d814e..556985c2b4 100644 --- a/beacon_node/network/src/sync/network_context/requests/blobs_by_root.rs +++ b/beacon_node/network/src/sync/network_context/requests/blobs_by_root.rs @@ -1,6 +1,6 @@ use lighthouse_network::rpc::methods::BlobsByRootRequest; use std::sync::Arc; -use types::{BlobSidecar, EthSpec, ForkContext, Hash256, blob_sidecar::BlobIdentifier}; +use types::{BlobSidecar, EthSpec, ForkContext, Hash256, data::BlobIdentifier}; use super::{ActiveRequestItems, LookupVerifyError}; diff --git a/beacon_node/network/src/sync/network_context/requests/data_columns_by_range.rs b/beacon_node/network/src/sync/network_context/requests/data_columns_by_range.rs index 9dabb2defa..74bdd0a114 100644 --- a/beacon_node/network/src/sync/network_context/requests/data_columns_by_range.rs +++ b/beacon_node/network/src/sync/network_context/requests/data_columns_by_range.rs @@ -28,18 +28,22 @@ impl<E: EthSpec> ActiveRequestItems for DataColumnsByRangeRequestItems<E> { { return Err(LookupVerifyError::UnrequestedSlot(data_column.slot())); } - if !self.request.columns.contains(&data_column.index) { - return Err(LookupVerifyError::UnrequestedIndex(data_column.index)); + if !self.request.columns.contains(data_column.index()) { + return Err(LookupVerifyError::UnrequestedIndex(*data_column.index())); } - if !data_column.verify_inclusion_proof() { + + if let DataColumnSidecar::Fulu(data_column) = data_column.as_ref() + && !data_column.verify_inclusion_proof() + { return Err(LookupVerifyError::InvalidInclusionProof); } + if self.items.iter().any(|existing| { - existing.slot() == data_column.slot() && existing.index == data_column.index + existing.slot() == data_column.slot() && *existing.index() == *data_column.index() }) { return Err(LookupVerifyError::DuplicatedData( data_column.slot(), - data_column.index, + *data_column.index(), )); } diff --git a/beacon_node/network/src/sync/network_context/requests/data_columns_by_root.rs b/beacon_node/network/src/sync/network_context/requests/data_columns_by_root.rs index 34df801eaa..5ad0f377c1 100644 --- a/beacon_node/network/src/sync/network_context/requests/data_columns_by_root.rs +++ b/beacon_node/network/src/sync/network_context/requests/data_columns_by_root.rs @@ -56,16 +56,24 @@ impl<E: EthSpec> ActiveRequestItems for DataColumnsByRootRequestItems<E> { if self.request.block_root != block_root { return Err(LookupVerifyError::UnrequestedBlockRoot(block_root)); } - if !data_column.verify_inclusion_proof() { + + if let DataColumnSidecar::Fulu(data_column) = data_column.as_ref() + && !data_column.verify_inclusion_proof() + { return Err(LookupVerifyError::InvalidInclusionProof); } - if !self.request.indices.contains(&data_column.index) { - return Err(LookupVerifyError::UnrequestedIndex(data_column.index)); + + if !self.request.indices.contains(data_column.index()) { + return Err(LookupVerifyError::UnrequestedIndex(*data_column.index())); } - if self.items.iter().any(|d| d.index == data_column.index) { + if self + .items + .iter() + .any(|d| *d.index() == *data_column.index()) + { return Err(LookupVerifyError::DuplicatedData( data_column.slot(), - data_column.index, + *data_column.index(), )); } diff --git a/beacon_node/network/src/sync/range_data_column_batch_request.rs b/beacon_node/network/src/sync/range_data_column_batch_request.rs index b912a6badc..4a6987a752 100644 --- a/beacon_node/network/src/sync/range_data_column_batch_request.rs +++ b/beacon_node/network/src/sync/range_data_column_batch_request.rs @@ -189,9 +189,15 @@ impl<T: BeaconChainTypes> RangeDataColumnBatchRequest<T> { .unique() .collect::<Vec<_>>(); + // TODO(gloas) no block signatures to check post-gloas, double check what to do here let column_block_signatures = columns .iter() - .map(|column| column.signed_block_header.signature.clone()) + .filter_map(|column| match column.as_ref() { + DataColumnSidecar::Fulu(column) => { + Some(column.signed_block_header.signature.clone()) + } + _ => None, + }) .unique() .collect::<Vec<_>>(); @@ -201,8 +207,8 @@ impl<T: BeaconChainTypes> RangeDataColumnBatchRequest<T> { // If there are no block roots, penalize all peers [] => { for column in &columns { - if let Some(naughty_peer) = column_to_peer.get(&column.index) { - naughty_peers.push((column.index, *naughty_peer)); + if let Some(naughty_peer) = column_to_peer.get(column.index()) { + naughty_peers.push((*column.index(), *naughty_peer)); } } continue; @@ -212,9 +218,9 @@ impl<T: BeaconChainTypes> RangeDataColumnBatchRequest<T> { for column in columns { if column_block_roots.contains(&column.block_root()) && block_root != column.block_root() - && let Some(naughty_peer) = column_to_peer.get(&column.index) + && let Some(naughty_peer) = column_to_peer.get(column.index()) { - naughty_peers.push((column.index, *naughty_peer)); + naughty_peers.push((*column.index(), *naughty_peer)); } } continue; @@ -227,17 +233,19 @@ impl<T: BeaconChainTypes> RangeDataColumnBatchRequest<T> { // If there are no block signatures, penalize all peers [] => { for column in &columns { - if let Some(naughty_peer) = column_to_peer.get(&column.index) { - naughty_peers.push((column.index, *naughty_peer)); + if let Some(naughty_peer) = column_to_peer.get(column.index()) { + naughty_peers.push((*column.index(), *naughty_peer)); } } continue; } // If theres more than one unique block signature, penalize the peers serving the - // invalid block signatures. + // invalid block signatures. This check is only relevant for Fulu. column_block_signatures => { for column in columns { - if column_block_signatures.contains(&column.signed_block_header.signature) + if let DataColumnSidecar::Fulu(column) = column.as_ref() + && column_block_signatures + .contains(&column.signed_block_header.signature) && block.signature() != &column.signed_block_header.signature && let Some(naughty_peer) = column_to_peer.get(&column.index) { @@ -251,8 +259,8 @@ impl<T: BeaconChainTypes> RangeDataColumnBatchRequest<T> { // if the block root doesn't match the columns block root, penalize the peers if block_root != column_block_root { for column in &columns { - if let Some(naughty_peer) = column_to_peer.get(&column.index) { - naughty_peers.push((column.index, *naughty_peer)); + if let Some(naughty_peer) = column_to_peer.get(column.index()) { + naughty_peers.push((*column.index(), *naughty_peer)); } } } @@ -260,13 +268,13 @@ impl<T: BeaconChainTypes> RangeDataColumnBatchRequest<T> { // If the block signature doesn't match the columns block signature, penalize the peers if block.signature() != column_block_signature { for column in &columns { - if let Some(naughty_peer) = column_to_peer.get(&column.index) { - naughty_peers.push((column.index, *naughty_peer)); + if let Some(naughty_peer) = column_to_peer.get(column.index()) { + naughty_peers.push((*column.index(), *naughty_peer)); } } } - let received_columns = columns.iter().map(|c| c.index).collect::<HashSet<_>>(); + let received_columns = columns.iter().map(|c| *c.index()).collect::<HashSet<_>>(); let missing_columns = expected_custody_columns .difference(&received_columns) diff --git a/beacon_node/network/src/sync/range_sync/chain.rs b/beacon_node/network/src/sync/range_sync/chain.rs index 4ce10e23ca..d533d8ed0d 100644 --- a/beacon_node/network/src/sync/range_sync/chain.rs +++ b/beacon_node/network/src/sync/range_sync/chain.rs @@ -3,22 +3,22 @@ use crate::metrics; use crate::network_beacon_processor::ChainSegmentProcessId; use crate::sync::batch::BatchId; use crate::sync::batch::{ - BatchConfig, BatchInfo, BatchOperationOutcome, BatchProcessingResult, BatchState, + BatchConfig, BatchInfo, BatchMetricsState, BatchOperationOutcome, BatchProcessingResult, + BatchState, }; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::network_context::{RangeRequestId, RpcRequestSendError, RpcResponseError}; use crate::sync::{BatchProcessResult, network_context::SyncNetworkContext}; use beacon_chain::BeaconChainTypes; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use lighthouse_network::service::api_types::Id; use lighthouse_network::{PeerAction, PeerId}; -use lighthouse_tracing::SPAN_SYNCING_CHAIN; use logging::crit; use std::collections::{BTreeMap, HashSet, btree_map::Entry}; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use strum::IntoStaticStr; -use tracing::{Span, debug, instrument, warn}; +use tracing::{Span, debug, error, instrument, warn}; use types::{ColumnIndex, Epoch, EthSpec, Hash256, Slot}; /// Blocks are downloaded in batches from peers. This constant specifies how many epochs worth of @@ -40,7 +40,7 @@ const BATCH_BUFFER_SIZE: u8 = 5; /// and continued is now in an inconsistent state. pub type ProcessingResult = Result<KeepChain, RemoveChain>; -type RpcBlocks<E> = Vec<RpcBlock<E>>; +type RpcBlocks<E> = Vec<RangeSyncBlock<E>>; type RangeSyncBatchInfo<E> = BatchInfo<E, RangeSyncBatchConfig<E>, RpcBlocks<E>>; type RangeSyncBatches<E> = BTreeMap<BatchId, RangeSyncBatchInfo<E>>; @@ -161,7 +161,7 @@ pub enum ChainSyncingState { impl<T: BeaconChainTypes> SyncingChain<T> { #[allow(clippy::too_many_arguments)] #[instrument( - name = SPAN_SYNCING_CHAIN, + name = "lh_syncing_chain", parent = None, level="debug", skip_all, @@ -235,6 +235,14 @@ impl<T: BeaconChainTypes> SyncingChain<T> { .sum() } + /// Returns the number of batches in the given metrics state. + pub fn count_batches_in_state(&self, state: BatchMetricsState) -> usize { + self.batches + .values() + .filter(|b| b.state().metrics_state() == state) + .count() + } + /// Removes a peer from the chain. /// If the peer has active batches, those are considered failed and re-requested. pub fn remove_peer(&mut self, peer_id: &PeerId) -> ProcessingResult { @@ -265,7 +273,7 @@ impl<T: BeaconChainTypes> SyncingChain<T> { batch_id: BatchId, peer_id: &PeerId, request_id: Id, - blocks: Vec<RpcBlock<T::EthSpec>>, + blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) -> ProcessingResult { let _guard = self.span.clone().entered(); // check if we have this batch @@ -942,10 +950,10 @@ impl<T: BeaconChainTypes> SyncingChain<T> { } } CouplingError::BlobPeerFailure(msg) => { - tracing::debug!(?batch_id, msg, "Blob peer failure"); + debug!(?batch_id, msg, "Blob peer failure"); } CouplingError::InternalError(msg) => { - tracing::error!(?batch_id, msg, "Block components coupling internal error"); + error!(?batch_id, msg, "Block components coupling internal error"); } } } @@ -1278,7 +1286,7 @@ impl<T: BeaconChainTypes> SyncingChain<T> { .iter() .filter(|&(_epoch, batch)| in_buffer(batch)) .count() - > BATCH_BUFFER_SIZE as usize + >= BATCH_BUFFER_SIZE as usize { return None; } diff --git a/beacon_node/network/src/sync/range_sync/chain_collection.rs b/beacon_node/network/src/sync/range_sync/chain_collection.rs index 1d57ee6c3d..a087fdecdf 100644 --- a/beacon_node/network/src/sync/range_sync/chain_collection.rs +++ b/beacon_node/network/src/sync/range_sync/chain_collection.rs @@ -6,6 +6,7 @@ use super::chain::{ChainId, ProcessingResult, RemoveChain, SyncingChain}; use super::sync_type::RangeSyncType; use crate::metrics; +use crate::sync::batch::BatchMetricsState; use crate::sync::network_context::SyncNetworkContext; use beacon_chain::{BeaconChain, BeaconChainTypes}; use fnv::FnvHashMap; @@ -17,6 +18,7 @@ use smallvec::SmallVec; use std::collections::HashMap; use std::collections::hash_map::Entry; use std::sync::Arc; +use strum::IntoEnumIterator; use tracing::{debug, error}; use types::EthSpec; use types::{Epoch, Hash256, Slot}; @@ -41,6 +43,13 @@ pub enum RangeSyncState { pub type SyncChainStatus = Result<Option<(RangeSyncType, Slot /* from */, Slot /* to */)>, &'static str>; +#[cfg(test)] +#[derive(Default, Debug)] +pub struct ChainCollectionMetrics { + pub chains_added: usize, + pub chains_removed: usize, +} + /// A collection of finalized and head chains currently being processed. pub struct ChainCollection<T: BeaconChainTypes> { /// The beacon chain for processing. @@ -51,6 +60,9 @@ pub struct ChainCollection<T: BeaconChainTypes> { head_chains: FnvHashMap<ChainId, SyncingChain<T>>, /// The current sync state of the process. state: RangeSyncState, + #[cfg(test)] + /// Used for testing assertions + metrics: ChainCollectionMetrics, } impl<T: BeaconChainTypes> ChainCollection<T> { @@ -60,12 +72,23 @@ impl<T: BeaconChainTypes> ChainCollection<T> { finalized_chains: FnvHashMap::default(), head_chains: FnvHashMap::default(), state: RangeSyncState::Idle, + #[cfg(test)] + metrics: <_>::default(), } } + #[cfg(test)] + pub(crate) fn metrics(&self) -> &ChainCollectionMetrics { + &self.metrics + } + /// Updates the Syncing state of the collection after a chain is removed. fn on_chain_removed(&mut self, id: &ChainId, was_syncing: bool, sync_type: RangeSyncType) { metrics::inc_counter_vec(&metrics::SYNCING_CHAINS_REMOVED, &[sync_type.as_str()]); + #[cfg(test)] + { + self.metrics.chains_removed += 1; + } self.update_metrics(); match self.state { @@ -351,7 +374,8 @@ impl<T: BeaconChainTypes> ChainCollection<T> { .iter() .map(|(id, chain)| (chain.available_peers(), !chain.is_syncing(), *id)) .collect::<Vec<_>>(); - preferred_ids.sort_unstable(); + // Sort in descending order + preferred_ids.sort_unstable_by(|a, b| b.cmp(a)); let mut syncing_chains = SmallVec::<[Id; PARALLEL_HEAD_CHAINS]>::new(); for (_, _, id) in preferred_ids { @@ -510,11 +534,34 @@ impl<T: BeaconChainTypes> ChainCollection<T> { ); collection.insert(id, new_chain); metrics::inc_counter_vec(&metrics::SYNCING_CHAINS_ADDED, &[sync_type.as_str()]); + #[cfg(test)] + { + self.metrics.chains_added += 1; + } self.update_metrics(); } } } + pub fn register_metrics(&self) { + for (sync_type, chains) in [ + ("range_finalized", &self.finalized_chains), + ("range_head", &self.head_chains), + ] { + for state in BatchMetricsState::iter() { + let count: usize = chains + .values() + .map(|chain| chain.count_batches_in_state(state)) + .sum(); + metrics::set_gauge_vec( + &metrics::SYNCING_CHAIN_BATCHES, + &[sync_type, state.into()], + count as i64, + ); + } + } + } + fn update_metrics(&self) { metrics::set_gauge_vec( &metrics::SYNCING_CHAINS_COUNT, diff --git a/beacon_node/network/src/sync/range_sync/mod.rs b/beacon_node/network/src/sync/range_sync/mod.rs index dd9f17bfd1..3b65e1c84a 100644 --- a/beacon_node/network/src/sync/range_sync/mod.rs +++ b/beacon_node/network/src/sync/range_sync/mod.rs @@ -5,6 +5,8 @@ mod chain_collection; mod range; mod sync_type; +#[cfg(test)] +pub use chain::RangeSyncBatchConfig; pub use chain::{ChainId, EPOCHS_PER_BATCH}; #[cfg(test)] pub use chain_collection::SyncChainStatus; diff --git a/beacon_node/network/src/sync/range_sync/range.rs b/beacon_node/network/src/sync/range_sync/range.rs index c9656ad1d0..6509ac3cb3 100644 --- a/beacon_node/network/src/sync/range_sync/range.rs +++ b/beacon_node/network/src/sync/range_sync/range.rs @@ -47,7 +47,7 @@ use crate::status::ToStatusMessage; use crate::sync::BatchProcessResult; use crate::sync::batch::BatchId; use crate::sync::network_context::{RpcResponseError, SyncNetworkContext}; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::{BeaconChain, BeaconChainTypes}; use lighthouse_network::rpc::GoodbyeReason; use lighthouse_network::service::api_types::Id; @@ -98,6 +98,11 @@ where self.failed_chains.keys().copied().collect() } + #[cfg(test)] + pub(crate) fn metrics(&self) -> &super::chain_collection::ChainCollectionMetrics { + self.chains.metrics() + } + pub fn state(&self) -> SyncChainStatus { self.chains.state() } @@ -208,7 +213,7 @@ where chain_id: ChainId, batch_id: BatchId, request_id: Id, - blocks: Vec<RpcBlock<T::EthSpec>>, + blocks: Vec<RangeSyncBlock<T::EthSpec>>, ) { // check if this chunk removes the chain match self.chains.call_by_id(chain_id, |chain| { @@ -371,6 +376,10 @@ where .update(network, &local, &mut self.awaiting_head_peers); } + pub fn register_metrics(&self) { + self.chains.register_metrics(); + } + /// Kickstarts sync. pub fn resume(&mut self, network: &mut SyncNetworkContext<T>) { for (removed_chain, sync_type, remove_reason) in diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index ef52f89678..a26996ec5e 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -1,73 +1,238 @@ +use super::*; use crate::NetworkMessage; -use crate::network_beacon_processor::NetworkBeaconProcessor; -use crate::sync::block_lookups::{ - BlockLookupSummary, PARENT_DEPTH_TOLERANCE, SINGLE_BLOCK_LOOKUP_MAX_ATTEMPTS, +use crate::network_beacon_processor::{ + ChainSegmentProcessId, InvalidBlockStorage, NetworkBeaconProcessor, }; +use crate::sync::block_lookups::{BlockLookupSummary, PARENT_DEPTH_TOLERANCE}; use crate::sync::{ SyncMessage, - manager::{BlockProcessType, BlockProcessingResult, SyncManager}, + manager::{BatchProcessResult, BlockProcessType, BlockProcessingResult, SyncManager}, }; -use std::sync::Arc; -use std::time::Duration; - -use super::*; - -use crate::sync::block_lookups::common::ResponseType; -use beacon_chain::observed_data_sidecars::Observe; +use beacon_chain::blob_verification::KzgVerifiedBlob; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ - AvailabilityPendingExecutedBlock, AvailabilityProcessingStatus, BlockError, - PayloadVerificationOutcome, PayloadVerificationStatus, - blob_verification::GossipVerifiedBlob, - block_verification_types::{AsBlock, BlockImportData}, + AvailabilityProcessingStatus, BlockError, EngineState, NotifyExecutionLayer, + block_verification_types::{AsBlock, AvailableBlockData}, data_availability_checker::Availability, test_utils::{ - BeaconChainHarness, EphemeralHarnessType, NumBlobs, generate_rand_block_and_blobs, - generate_rand_block_and_data_columns, test_spec, + AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, NumBlobs, + generate_rand_block_and_blobs, test_spec, }, - validator_monitor::timestamp_now, }; -use beacon_processor::WorkEvent; +use beacon_processor::{BeaconProcessorChannels, DuplicateCache, Work, WorkEvent}; +use educe::Educe; +use itertools::Itertools; use lighthouse_network::discovery::CombinedKey; use lighthouse_network::{ - NetworkConfig, NetworkGlobals, PeerId, - rpc::{RPCError, RequestType, RpcErrorResponse}, - service::api_types::{ - AppRequestId, DataColumnsByRootRequestId, DataColumnsByRootRequester, Id, - SingleLookupReqId, SyncRequestId, - }, + NetworkConfig, NetworkGlobals, PeerAction, PeerId, + rpc::{RPCError, RequestType}, + service::api_types::{AppRequestId, SyncRequestId}, types::SyncState, }; use slot_clock::{SlotClock, TestingSlotClock}; +use std::sync::Arc; +use std::time::Duration; use tokio::sync::mpsc; use tracing::info; use types::{ - BeaconState, BeaconStateBase, BlobSidecar, BlockImportSource, DataColumnSidecar, EthSpec, - ForkContext, ForkName, Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, - data_column_sidecar::ColumnIndex, - test_utils::{SeedableRng, TestRandom, XorShiftRng}, + BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, EthSpec, ForkContext, ForkName, + Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, + test_utils::{SeedableRng, XorShiftRng}, }; const D: Duration = Duration::new(0, 0); -const PARENT_FAIL_TOLERANCE: u8 = SINGLE_BLOCK_LOOKUP_MAX_ATTEMPTS; -type DCByRootIds = Vec<DCByRootId>; -type DCByRootId = (SyncRequestId, Vec<ColumnIndex>); + +/// Configuration for how the test rig should respond to sync requests. +/// +/// Controls simulated peer behavior during lookup tests, including RPC errors, +/// invalid responses, and custom block processing results. Use builder methods +/// to configure specific failure scenarios. +#[derive(Default, Educe)] +#[educe(Debug)] +pub struct SimulateConfig { + return_rpc_error: Option<RPCError>, + return_wrong_blocks_n_times: usize, + return_wrong_sidecar_for_block_n_times: usize, + return_no_blocks_n_times: usize, + return_no_data_n_times: usize, + return_too_few_data_n_times: usize, + return_no_columns_on_indices_n_times: usize, + return_no_columns_on_indices: Vec<ColumnIndex>, + skip_by_range_routes: bool, + // Use a callable fn because BlockProcessingResult does not implement Clone + #[educe(Debug(ignore))] + process_result_conditional: + Option<Box<dyn Fn(Hash256) -> Option<BlockProcessingResult> + Send + Sync>>, + // Import a block directly before processing it (for simulating race conditions) + import_block_before_process: HashSet<Hash256>, + /// Number of range batch processing attempts that return FaultyFailure + range_faulty_failures: usize, + /// Number of range batch processing attempts that return NonFaultyFailure + range_non_faulty_failures: usize, + /// Number of BlocksByRange requests that return empty (no blocks) + return_no_range_blocks_n_times: usize, + /// Number of DataColumnsByRange requests that return empty (no columns) + return_no_range_columns_n_times: usize, + /// Number of DataColumnsByRange requests that return columns with unrequested indices + return_wrong_range_column_indices_n_times: usize, + /// Number of DataColumnsByRange requests that return columns with unrequested slots + return_wrong_range_column_slots_n_times: usize, + /// Number of DataColumnsByRange requests that return fewer columns than requested + /// (drops half the columns). Triggers CouplingError::DataColumnPeerFailure → retry_partial_batch + return_partial_range_columns_n_times: usize, + /// Set EE offline at start, bring back online after this many BlocksByRange responses + ee_offline_for_n_range_responses: Option<usize>, + /// Disconnect all peers after this many successful BlocksByRange responses. + successful_range_responses_before_disconnect: Option<usize>, +} + +impl SimulateConfig { + pub(super) fn new() -> Self { + Self::default() + } + + pub(super) fn happy_path() -> Self { + Self::default() + } + + fn return_no_blocks_always(mut self) -> Self { + self.return_no_blocks_n_times = usize::MAX; + self + } + + fn return_no_blocks_once(mut self) -> Self { + self.return_no_blocks_n_times = 1; + self + } + + fn return_no_data_once(mut self) -> Self { + self.return_no_data_n_times = 1; + self + } + + fn return_wrong_blocks_once(mut self) -> Self { + self.return_wrong_blocks_n_times = 1; + self + } + + fn return_wrong_sidecar_for_block_once(mut self) -> Self { + self.return_wrong_sidecar_for_block_n_times = 1; + self + } + + fn return_too_few_data_once(mut self) -> Self { + self.return_too_few_data_n_times = 1; + self + } + + fn return_no_columns_on_indices(mut self, indices: &[ColumnIndex], times: usize) -> Self { + self.return_no_columns_on_indices_n_times = times; + self.return_no_columns_on_indices = indices.to_vec(); + self + } + + pub(super) fn return_rpc_error(mut self, error: RPCError) -> Self { + self.return_rpc_error = Some(error); + self + } + + fn no_range_sync(mut self) -> Self { + self.skip_by_range_routes = true; + self + } + + fn with_process_result<F>(mut self, f: F) -> Self + where + F: Fn() -> BlockProcessingResult + Send + Sync + 'static, + { + self.process_result_conditional = Some(Box::new(move |_| Some(f()))); + self + } + + fn with_import_block_before_process(mut self, block_root: Hash256) -> Self { + self.import_block_before_process.insert(block_root); + self + } + + pub(super) fn with_range_faulty_failures(mut self, n: usize) -> Self { + self.range_faulty_failures = n; + self + } + + pub(super) fn with_range_non_faulty_failures(mut self, n: usize) -> Self { + self.range_non_faulty_failures = n; + self + } + + pub(super) fn with_no_range_blocks_n_times(mut self, n: usize) -> Self { + self.return_no_range_blocks_n_times = n; + self + } + + pub(super) fn with_no_range_columns_n_times(mut self, n: usize) -> Self { + self.return_no_range_columns_n_times = n; + self + } + + pub(super) fn with_wrong_range_column_indices_n_times(mut self, n: usize) -> Self { + self.return_wrong_range_column_indices_n_times = n; + self + } + + pub(super) fn with_wrong_range_column_slots_n_times(mut self, n: usize) -> Self { + self.return_wrong_range_column_slots_n_times = n; + self + } + + pub(super) fn with_partial_range_columns_n_times(mut self, n: usize) -> Self { + self.return_partial_range_columns_n_times = n; + self + } + + pub(super) fn with_ee_offline_for_n_range_responses(mut self, n: usize) -> Self { + self.ee_offline_for_n_range_responses = Some(n); + self + } + + pub(super) fn with_disconnect_after_range_requests(mut self, n: usize) -> Self { + self.successful_range_responses_before_disconnect = Some(n); + self + } +} + +fn genesis_fork() -> ForkName { + test_spec::<E>().fork_name_at_slot::<E>(Slot::new(0)) +} + +pub(crate) struct TestRigConfig { + fulu_test_type: FuluTestType, + /// Override the node custody type derived from `fulu_test_type` + node_custody_type_override: Option<NodeCustodyType>, +} impl TestRig { - pub fn test_setup() -> Self { + pub(crate) fn new(test_rig_config: TestRigConfig) -> Self { // Use `fork_from_env` logic to set correct fork epochs - let spec = test_spec::<E>(); + let spec = Arc::new(test_spec::<E>()); + let clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + Duration::from_secs(12), + ); // Initialise a new beacon chain let harness = BeaconChainHarness::<EphemeralHarnessType<E>>::builder(E) - .spec(Arc::new(spec)) + .spec(spec.clone()) .deterministic_keypairs(1) .fresh_ephemeral_store() .mock_execution_layer() - .testing_slot_clock(TestingSlotClock::new( - Slot::new(0), - Duration::from_secs(0), - Duration::from_secs(12), - )) + .testing_slot_clock(clock.clone()) + .node_custody_type( + test_rig_config + .node_custody_type_override + .unwrap_or_else(|| test_rig_config.fulu_test_type.we_node_custody_type()), + ) .build(); let chain = harness.chain.clone(); @@ -87,12 +252,23 @@ impl TestRig { network_config, chain.spec.clone(), )); - let (beacon_processor, beacon_processor_rx) = NetworkBeaconProcessor::null_for_testing( - globals, + + let BeaconProcessorChannels { + beacon_processor_tx, + beacon_processor_rx, + } = <_>::default(); + + let beacon_processor = NetworkBeaconProcessor { + beacon_processor_send: beacon_processor_tx, + duplicate_cache: DuplicateCache::default(), + chain: chain.clone(), + // TODO: What is this sender used for? + network_tx: mpsc::unbounded_channel().0, sync_tx, - chain.clone(), - harness.runtime.task_executor.clone(), - ); + network_globals: globals.clone(), + invalid_block_storage: InvalidBlockStorage::Disabled, + executor: harness.runtime.task_executor.clone(), + }; let fork_name = chain.spec.fork_name_at_slot::<E>(chain.slot().unwrap()); @@ -101,8 +277,6 @@ impl TestRig { .network_globals .set_sync_state(SyncState::Synced); - let spec = chain.spec.clone(); - // deterministic seed let rng_08 = <rand_chacha_03::ChaCha20Rng as rand_08::SeedableRng>::from_seed([0u8; 32]); let rng = ChaCha20Rng::from_seed([0u8; 32]); @@ -115,6 +289,7 @@ impl TestRig { network_rx, network_rx_queue: vec![], sync_rx, + sync_rx_queue: vec![], rng_08, rng, network_globals: beacon_processor.network_globals.clone(), @@ -128,37 +303,1160 @@ impl TestRig { ), harness, fork_name, - spec, + network_blocks_by_root: <_>::default(), + network_blocks_by_slot: <_>::default(), + penalties: <_>::default(), + seen_lookups: <_>::default(), + requests: <_>::default(), + complete_strategy: <_>::default(), + initial_block_lookups_metrics: <_>::default(), + fulu_test_type: test_rig_config.fulu_test_type, } } - fn test_setup_after_deneb_before_fulu() -> Option<Self> { - let r = Self::test_setup(); - if r.after_deneb() && !r.fork_name.fulu_enabled() { - Some(r) + pub fn default() -> Self { + // Before Fulu, FuluTestType is irrelevant + Self::new(TestRigConfig { + fulu_test_type: FuluTestType::WeFullnodeThemSupernode, + node_custody_type_override: None, + }) + } + + #[allow(dead_code)] + pub fn with_custody_type(node_custody_type: NodeCustodyType) -> Self { + Self::new(TestRigConfig { + fulu_test_type: FuluTestType::WeFullnodeThemSupernode, + node_custody_type_override: Some(node_custody_type), + }) + } + + /// Runs the sync simulation until all event queues are empty. + /// + /// Processes events from sync_rx (sink), beacon processor, and network queues in fixed + /// priority order each tick. Handles completed work before pulling new requests. + pub(super) async fn simulate(&mut self, complete_strategy: SimulateConfig) { + self.complete_strategy = complete_strategy; + self.log(&format!( + "Running simulate with config {:?}", + self.complete_strategy + )); + + // Set EE offline at the start if configured + if self + .complete_strategy + .ee_offline_for_n_range_responses + .is_some() + { + self.sync_manager + .update_execution_engine_state(EngineState::Offline); + } + + let mut i = 0; + + loop { + i += 1; + + // Record current status + for BlockLookupSummary { + id, + block_root, + peers, + .. + } in self.active_single_lookups() + { + let lookup = self.seen_lookups.entry(id).or_insert(SeenLookup { + id, + block_root, + seen_peers: <_>::default(), + }); + for peer in peers { + lookup.seen_peers.insert(peer); + } + } + + // Drain all channels into queues + while let Ok(ev) = self.network_rx.try_recv() { + self.network_rx_queue.push(ev); + } + while let Ok(ev) = self.beacon_processor_rx.try_recv() { + self.beacon_processor_rx_queue.push(ev); + } + while let Ok(ev) = self.sync_rx.try_recv() { + self.sync_rx_queue.push(ev); + } + + // Process one event per tick in fixed priority: sink → processor → network + if !self.sync_rx_queue.is_empty() { + let sync_message = self.sync_rx_queue.remove(0); + self.log(&format!( + "Tick {i}: sync_rx event: {}", + Into::<&'static str>::into(&sync_message) + )); + self.sync_manager.handle_message(sync_message); + } else if !self.beacon_processor_rx_queue.is_empty() { + let event = self.beacon_processor_rx_queue.remove(0); + self.log(&format!("Tick {i}: beacon_processor event: {event:?}")); + match event.work { + Work::RpcBlock { + process_fn, + beacon_block_root, + } => { + // Import block before processing if configured (for simulating race conditions) + if self + .complete_strategy + .import_block_before_process + .contains(&beacon_block_root) + { + self.log(&format!( + "Importing block {} before processing (race condition simulation)", + beacon_block_root + )); + self.import_block_by_root(beacon_block_root).await; + } + + if let Some(f) = self.complete_strategy.process_result_conditional.as_ref() + && let Some(result) = f(beacon_block_root) + { + let id = self.lookup_by_root(beacon_block_root).id; + self.log(&format!( + "Sending custom process result to lookup id {id}: {result:?}" + )); + self.push_sync_message(SyncMessage::BlockComponentProcessed { + process_type: BlockProcessType::SingleBlock { id }, + result, + }); + } else { + process_fn.await + } + } + Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) => { + process_fn.await + } + Work::ChainSegment { + process_fn, + process_id: (chain_id, batch_epoch), + } => { + let sync_type = + ChainSegmentProcessId::RangeBatchId(chain_id, batch_epoch.into()); + if self.complete_strategy.range_faulty_failures > 0 { + self.complete_strategy.range_faulty_failures -= 1; + self.push_sync_message(SyncMessage::BatchProcessed { + sync_type, + result: BatchProcessResult::FaultyFailure { + imported_blocks: 0, + penalty: PeerAction::LowToleranceError, + }, + }); + } else if self.complete_strategy.range_non_faulty_failures > 0 { + self.complete_strategy.range_non_faulty_failures -= 1; + self.push_sync_message(SyncMessage::BatchProcessed { + sync_type, + result: BatchProcessResult::NonFaultyFailure, + }); + } else { + process_fn.await; + } + } + Work::Reprocess(_) => {} // ignore + other => panic!("Unsupported Work event {}", other.str_id()), + } + } else if !self.network_rx_queue.is_empty() { + let event = self.network_rx_queue.remove(0); + self.log(&format!("Tick {i}: network_rx event: {event:?}")); + match event { + NetworkMessage::SendRequest { + peer_id, + request, + app_request_id, + } => { + self.simulate_on_request(peer_id, request, app_request_id); + } + NetworkMessage::ReportPeer { peer_id, msg, .. } => { + self.penalties.push(ReportedPenalty { peer_id, msg }); + } + _ => {} + } + } else { + break; + } + } + + self.log("No more events in simulation"); + self.log(&format!( + "Lookup metrics: {:?}", + self.sync_manager.block_lookups().metrics() + )); + self.log(&format!( + "Range sync metrics: {:?}", + self.sync_manager.range_sync().metrics() + )); + self.log(&format!( + "Max known slot: {}, Head slot: {}", + self.max_known_slot(), + self.head_slot() + )); + self.log(&format!("Penalties: {:?}", self.penalties)); + self.log(&format!( + "Total requests {}: {:?}", + self.requests.len(), + self.requests_count() + )) + } + + fn simulate_on_request( + &mut self, + peer_id: PeerId, + request: RequestType<E>, + app_req_id: AppRequestId, + ) { + self.requests.push((request.clone(), app_req_id)); + + if let AppRequestId::Sync(req_id) = app_req_id + && let Some(error) = self.complete_strategy.return_rpc_error.take() + { + self.log(&format!( + "Completing request {req_id:?} to {peer_id} with RPCError {error:?}" + )); + self.send_sync_message(SyncMessage::RpcError { + sync_request_id: req_id, + peer_id, + error, + }); + return; + } + + match (request, app_req_id) { + (RequestType::BlocksByRoot(req), AppRequestId::Sync(req_id)) => { + let blocks = + req.block_roots() + .iter() + .filter_map(|block_root| { + if self.complete_strategy.return_no_blocks_n_times > 0 { + self.complete_strategy.return_no_blocks_n_times -= 1; + None + } else if self.complete_strategy.return_wrong_blocks_n_times > 0 { + self.complete_strategy.return_wrong_blocks_n_times -= 1; + Some(Arc::new(self.rand_block())) + } else { + Some(self.network_blocks_by_root + .get(block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown block: {block_root:?}") + }) + .block_cloned()) + } + }) + .collect::<Vec<_>>(); + + self.send_rpc_blocks_response(req_id, peer_id, &blocks); + } + + (RequestType::BlobsByRoot(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.return_no_data_n_times > 0 { + self.complete_strategy.return_no_data_n_times -= 1; + return self.send_rpc_blobs_response(req_id, peer_id, &[]); + } + + let mut blobs = req + .blob_ids + .iter() + .map(|id| { + self.network_blocks_by_root + .get(&id.block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown block: {id:?}") + }) + .block_data() + .blobs() + .unwrap_or_else(|| panic!("Block {id:?} has no blobs")) + .iter() + .find(|blob| blob.index == id.index) + .unwrap_or_else(|| panic!("Blob id {id:?} not avail")) + .clone() + }) + .collect::<Vec<_>>(); + + if self.complete_strategy.return_too_few_data_n_times > 0 { + self.complete_strategy.return_too_few_data_n_times -= 1; + blobs.pop(); + } + + if self + .complete_strategy + .return_wrong_sidecar_for_block_n_times + > 0 + { + self.complete_strategy + .return_wrong_sidecar_for_block_n_times -= 1; + let first = blobs.first_mut().expect("empty blobs"); + let mut blob = Arc::make_mut(first).clone(); + blob.signed_block_header.message.body_root = Hash256::ZERO; + *first = Arc::new(blob); + } + + self.send_rpc_blobs_response(req_id, peer_id, &blobs); + } + + (RequestType::DataColumnsByRoot(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.return_no_data_n_times > 0 { + self.complete_strategy.return_no_data_n_times -= 1; + return self.send_rpc_columns_response(req_id, peer_id, &[]); + } + + let will_omit_columns = req.data_column_ids.iter().any(|id| { + id.columns.iter().any(|c| { + self.complete_strategy + .return_no_columns_on_indices + .contains(c) + }) + }); + let columns_to_omit = if will_omit_columns + && self.complete_strategy.return_no_columns_on_indices_n_times > 0 + { + self.log(&format!("OMIT {:?}", req)); + self.complete_strategy.return_no_columns_on_indices_n_times -= 1; + self.complete_strategy.return_no_columns_on_indices.clone() + } else { + vec![] + }; + + let mut columns = req + .data_column_ids + .iter() + .flat_map(|id| { + let block_columns = self + .network_blocks_by_root + .get(&id.block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown block: {id:?}") + }) + .block_data() + .data_columns() + .unwrap_or_else(|| panic!("Block id {id:?} has no columns")); + id.columns + .iter() + .filter(|index| !columns_to_omit.contains(index)) + .map(move |index| { + block_columns + .iter() + .find(|c| *c.index() == *index) + .unwrap_or_else(|| { + panic!("Column {index:?} {:?} not found", id.block_root) + }) + .clone() + }) + }) + .collect::<Vec<_>>(); + + if self.complete_strategy.return_too_few_data_n_times > 0 { + self.complete_strategy.return_too_few_data_n_times -= 1; + columns.pop(); + } + + if self + .complete_strategy + .return_wrong_sidecar_for_block_n_times + > 0 + { + self.complete_strategy + .return_wrong_sidecar_for_block_n_times -= 1; + let first = columns.first_mut().expect("empty columns"); + let column = Arc::make_mut(first); + column + .signed_block_header_mut() + .expect("not fulu") + .message + .body_root = Hash256::ZERO; + } + self.send_rpc_columns_response(req_id, peer_id, &columns); + } + + (RequestType::BlocksByRange(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.skip_by_range_routes { + return; + } + + // Check if we should disconnect all peers instead of continuing + if let Some(ref mut remaining) = self + .complete_strategy + .successful_range_responses_before_disconnect + { + if *remaining == 0 { + // Disconnect all peers — remaining responses become "late" + for peer in self.get_connected_peers() { + self.peer_disconnected(peer); + } + return; + } else { + *remaining -= 1; + } + } + + // Return empty response N times to simulate peer returning no blocks + if self.complete_strategy.return_no_range_blocks_n_times > 0 { + self.complete_strategy.return_no_range_blocks_n_times -= 1; + self.send_rpc_blocks_response(req_id, peer_id, &[]); + } else { + let blocks = (*req.start_slot()..req.start_slot() + req.count()) + .filter_map(|slot| { + self.network_blocks_by_slot + .get(&Slot::new(slot)) + .map(|block| block.block_cloned()) + }) + .collect::<Vec<_>>(); + self.send_rpc_blocks_response(req_id, peer_id, &blocks); + } + + // Bring EE back online after N range responses + if let Some(ref mut remaining) = + self.complete_strategy.ee_offline_for_n_range_responses + { + if *remaining == 0 { + self.sync_manager + .update_execution_engine_state(EngineState::Online); + self.complete_strategy.ee_offline_for_n_range_responses = None; + } else { + *remaining -= 1; + } + } + } + + (RequestType::BlobsByRange(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.skip_by_range_routes { + return; + } + + // Note: This function is permissive, blocks may have zero blobs and it won't + // error. Some caveats: + // - The genesis block never has blobs + // - Some blocks may not have blobs as the blob count is random + let blobs = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().blobs()) + .flat_map(|blobs| blobs.into_iter()) + .collect::<Vec<_>>(); + self.send_rpc_blobs_response(req_id, peer_id, &blobs); + } + + (RequestType::DataColumnsByRange(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.skip_by_range_routes { + return; + } + + // Return empty columns N times + if self.complete_strategy.return_no_range_columns_n_times > 0 { + self.complete_strategy.return_no_range_columns_n_times -= 1; + self.send_rpc_columns_response(req_id, peer_id, &[]); + return; + } + + // Return columns with unrequested indices N times. + // Note: for supernodes this returns no columns since they custody all indices. + if self + .complete_strategy + .return_wrong_range_column_indices_n_times + > 0 + { + self.complete_strategy + .return_wrong_range_column_indices_n_times -= 1; + let wrong_columns = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().data_columns()) + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| !req.columns.contains(c.index())) + }) + .collect::<Vec<_>>(); + self.send_rpc_columns_response(req_id, peer_id, &wrong_columns); + return; + } + + // Return columns from an out-of-range slot N times + if self + .complete_strategy + .return_wrong_range_column_slots_n_times + > 0 + { + self.complete_strategy + .return_wrong_range_column_slots_n_times -= 1; + // Get a column from a slot AFTER the requested range + let wrong_slot = req.start_slot + req.count; + let wrong_columns = self + .network_blocks_by_slot + .get(&Slot::new(wrong_slot)) + .and_then(|block| block.block_data().data_columns()) + .into_iter() + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| req.columns.contains(c.index())) + }) + .collect::<Vec<_>>(); + self.send_rpc_columns_response(req_id, peer_id, &wrong_columns); + return; + } + + // Return only half the requested columns N times — triggers CouplingError + if self.complete_strategy.return_partial_range_columns_n_times > 0 { + self.complete_strategy.return_partial_range_columns_n_times -= 1; + let columns = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().data_columns()) + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| req.columns.contains(c.index())) + }) + .enumerate() + .filter(|(i, _)| i % 2 == 0) // keep every other column + .map(|(_, c)| c) + .collect::<Vec<_>>(); + self.send_rpc_columns_response(req_id, peer_id, &columns); + return; + } + + let columns = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().data_columns()) + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| req.columns.contains(c.index())) + }) + .collect::<Vec<_>>(); + self.send_rpc_columns_response(req_id, peer_id, &columns); + } + + (RequestType::Status(_req), AppRequestId::Router) => { + // Ignore Status requests for now + } + + other => panic!("Request not supported: {app_req_id:?} {other:?}"), + } + } + + fn send_rpc_blocks_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + blocks: &[Arc<SignedBeaconBlock<E>>], + ) { + let slots = blocks.iter().map(|block| block.slot()).collect::<Vec<_>>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with blocks {slots:?}" + )); + + for block in blocks { + self.push_sync_message(SyncMessage::RpcBlock { + sync_request_id, + peer_id, + beacon_block: Some(block.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcBlock { + sync_request_id, + peer_id, + beacon_block: None, + seen_timestamp: D, + }); + } + + fn send_rpc_blobs_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + blobs: &[Arc<BlobSidecar<E>>], + ) { + let slots = blobs + .iter() + .map(|block| block.slot()) + .unique() + .collect::<Vec<_>>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with blobs {slots:?}" + )); + + for blob in blobs { + self.push_sync_message(SyncMessage::RpcBlob { + sync_request_id, + peer_id, + blob_sidecar: Some(blob.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcBlob { + sync_request_id, + peer_id, + blob_sidecar: None, + seen_timestamp: D, + }); + } + + fn send_rpc_columns_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + columns: &[Arc<DataColumnSidecar<E>>], + ) { + let slots = columns + .iter() + .map(|block| block.slot()) + .unique() + .collect::<Vec<_>>(); + let indices = columns + .iter() + .map(|column| *column.index()) + .unique() + .collect::<Vec<_>>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with columns {slots:?} indices {indices:?}" + )); + + for column in columns { + self.push_sync_message(SyncMessage::RpcDataColumn { + sync_request_id, + peer_id, + data_column: Some(column.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcDataColumn { + sync_request_id, + peer_id, + data_column: None, + seen_timestamp: D, + }); + } + + // Preparation steps + + /// Returns the block root of the tip of the built chain + pub(super) async fn build_chain(&mut self, block_count: usize) -> Hash256 { + let mut blocks = vec![]; + + // Initialise a new beacon chain + let external_harness = BeaconChainHarness::<EphemeralHarnessType<E>>::builder(E) + .spec(self.harness.spec.clone()) + .deterministic_keypairs(1) + .fresh_ephemeral_store() + .mock_execution_layer() + .testing_slot_clock(self.harness.chain.slot_clock.clone()) + // Make the external harness a supernode so all columns are available + .node_custody_type(NodeCustodyType::Supernode) + .build(); + // Ensure all blocks have data. Otherwise, the triggers for unknown blob parent and unknown + // data column parent fail. + external_harness + .execution_block_generator() + .set_min_blob_count(1); + + // Add genesis block for completeness + let genesis_block = external_harness.get_head_block(); + self.network_blocks_by_root + .insert(genesis_block.canonical_root(), genesis_block.clone()); + self.network_blocks_by_slot + .insert(genesis_block.slot(), genesis_block); + + for i in 0..block_count { + external_harness.advance_slot(); + let block_root = external_harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + let block = external_harness.get_full_block(&block_root); + let block_root = block.canonical_root(); + let block_slot = block.slot(); + self.network_blocks_by_root + .insert(block_root, block.clone()); + self.network_blocks_by_slot.insert(block_slot, block); + self.log(&format!( + "Produced block {} index {i} in external harness", + block_slot, + )); + blocks.push((block_slot, block_root)); + } + + // Re-log to have a nice list of block roots at the end + for block in &blocks { + self.log(&format!("Build chain {block:?}")); + } + + // Auto-update the clock on the main harness to accept the blocks + self.harness + .set_current_slot(external_harness.get_current_slot()); + + blocks.last().expect("empty blocks").1 + } + + fn corrupt_last_block_signature(&mut self) { + let range_sync_block = self.get_last_block().clone(); + let mut block = (*range_sync_block.block_cloned()).clone(); + let blobs = range_sync_block.block_data().blobs(); + let columns = range_sync_block.block_data().data_columns(); + *block.signature_mut() = self.valid_signature(); + self.re_insert_block(Arc::new(block), blobs, columns); + } + + fn valid_signature(&mut self) -> bls::Signature { + let keypair = bls::Keypair::random(); + let msg = Hash256::random(); + keypair.sk.sign(msg) + } + + fn corrupt_last_blob_proposer_signature(&mut self) { + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let mut blobs = range_sync_block + .block_data() + .blobs() + .expect("no blobs") + .into_iter() + .collect::<Vec<_>>(); + let columns = range_sync_block.block_data().data_columns(); + let first = blobs.first_mut().expect("empty blobs"); + Arc::make_mut(first).signed_block_header.signature = self.valid_signature(); + let max_blobs = + self.harness + .spec + .max_blobs_per_block(block.slot().epoch(E::slots_per_epoch())) as usize; + let blobs = + types::BlobSidecarList::new(blobs, max_blobs).expect("invalid blob sidecar list"); + self.re_insert_block(block, Some(blobs), columns); + } + + fn corrupt_last_blob_kzg_proof(&mut self) { + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let mut blobs = range_sync_block + .block_data() + .blobs() + .expect("no blobs") + .into_iter() + .collect::<Vec<_>>(); + let columns = range_sync_block.block_data().data_columns(); + let first = blobs.first_mut().expect("empty blobs"); + Arc::make_mut(first).kzg_proof = kzg::KzgProof::empty(); + let max_blobs = + self.harness + .spec + .max_blobs_per_block(block.slot().epoch(E::slots_per_epoch())) as usize; + let blobs = + types::BlobSidecarList::new(blobs, max_blobs).expect("invalid blob sidecar list"); + self.re_insert_block(block, Some(blobs), columns); + } + + fn corrupt_last_column_proposer_signature(&mut self) { + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let blobs = range_sync_block.block_data().blobs(); + let mut columns = range_sync_block + .block_data() + .data_columns() + .expect("no columns"); + let first = columns.first_mut().expect("empty columns"); + Arc::make_mut(first) + .signed_block_header_mut() + .expect("not fulu") + .signature = self.valid_signature(); + self.re_insert_block(block, blobs, Some(columns)); + } + + fn corrupt_last_column_kzg_proof(&mut self) { + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let blobs = range_sync_block.block_data().blobs(); + let mut columns = range_sync_block + .block_data() + .data_columns() + .expect("no columns"); + let first = columns.first_mut().expect("empty columns"); + let column = Arc::make_mut(first); + let proof = column.kzg_proofs_mut().first_mut().expect("no kzg proofs"); + *proof = kzg::KzgProof::empty(); + self.re_insert_block(block, blobs, Some(columns)); + } + + fn get_last_block(&self) -> &RangeSyncBlock<E> { + let (_, last_block) = self + .network_blocks_by_root + .iter() + .max_by_key(|(_, block)| block.slot()) + .expect("no blocks"); + last_block + } + + fn re_insert_block( + &mut self, + block: Arc<SignedBeaconBlock<E>>, + blobs: Option<types::BlobSidecarList<E>>, + columns: Option<types::DataColumnSidecarList<E>>, + ) { + self.network_blocks_by_slot.clear(); + self.network_blocks_by_root.clear(); + let block_root = block.canonical_root(); + let block_slot = block.slot(); + let block_data = if let Some(columns) = columns { + AvailableBlockData::new_with_data_columns(columns) + } else if let Some(blobs) = blobs { + AvailableBlockData::new_with_blobs(blobs) + } else { + AvailableBlockData::NoData + }; + let range_sync_block = RangeSyncBlock::new( + block, + block_data, + &self.harness.chain.data_availability_checker, + self.harness.chain.spec.clone(), + ) + .unwrap(); + self.network_blocks_by_slot + .insert(block_slot, range_sync_block.clone()); + self.network_blocks_by_root + .insert(block_root, range_sync_block); + } + + /// Trigger a lookup with the last created block + fn trigger_with_last_block(&mut self) { + let peer_id = match self.fulu_test_type.them_node_custody_type() { + NodeCustodyType::Fullnode => self.new_connected_peer(), + NodeCustodyType::Supernode | NodeCustodyType::SemiSupernode => { + self.new_connected_supernode_peer() + } + }; + let last_block = self.get_last_block().canonical_root(); + self.trigger_unknown_block_from_attestation(last_block, peer_id); + } + + fn block_at_slot(&self, slot: u64) -> Arc<SignedBeaconBlock<E>> { + self.network_blocks_by_slot + .get(&Slot::new(slot)) + .unwrap_or_else(|| panic!("No block for slot {slot}")) + .block_cloned() + } + + fn block_root_at_slot(&self, slot: u64) -> Hash256 { + self.block_at_slot(slot).canonical_root() + } + + fn trigger_with_block_at_slot(&mut self, slot: u64) { + let peer_id = self.new_connected_supernode_peer(); + let block = self.block_at_slot(slot); + self.trigger_unknown_block_from_attestation(block.canonical_root(), peer_id); + } + + async fn build_chain_and_trigger_last_block(&mut self, block_count: usize) { + self.build_chain(block_count).await; + self.trigger_with_last_block(); + } + + /// Import blocks for slots 1..=up_to_slot into the local chain (advance local head) + pub(super) async fn import_blocks_up_to_slot(&mut self, up_to_slot: u64) { + for slot in 1..=up_to_slot { + let rpc_block = self + .network_blocks_by_slot + .get(&Slot::new(slot)) + .unwrap_or_else(|| panic!("No block at slot {slot}")) + .clone(); + let block_root = rpc_block.canonical_root(); + self.harness + .chain + .process_block( + block_root, + rpc_block, + NotifyExecutionLayer::Yes, + BlockImportSource::Gossip, + || Ok(()), + ) + .await + .unwrap(); + } + self.harness.chain.recompute_head_at_current_slot().await; + } + + /// Import a block directly into the chain without going through lookup sync + async fn import_block_by_root(&mut self, block_root: Hash256) { + let range_sync_block = self + .network_blocks_by_root + .get(&block_root) + .unwrap_or_else(|| panic!("No block for root {block_root}")) + .clone(); + + self.harness + .chain + .process_block( + block_root, + range_sync_block, + NotifyExecutionLayer::Yes, + BlockImportSource::RangeSync, + || Ok(()), + ) + .await + .unwrap(); + + self.harness.chain.recompute_head_at_current_slot().await; + } + + fn trigger_with_last_unknown_block_parent(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let last_block = self.get_last_block().block_cloned(); + self.trigger_unknown_parent_block(peer_id, last_block); + } + + fn trigger_with_last_unknown_blob_parent(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let blobs = self + .get_last_block() + .block_data() + .blobs() + .expect("no blobs"); + let blob = blobs.first().expect("empty blobs"); + self.trigger_unknown_parent_blob(peer_id, blob.clone()); + } + + fn trigger_with_last_unknown_data_column_parent(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let columns = self + .get_last_block() + .block_data() + .data_columns() + .expect("No data columns"); + let column = columns.first().expect("empty columns"); + self.trigger_unknown_parent_column(peer_id, column.clone()); + } + + // Post-test assertions + + pub(super) fn head_slot(&self) -> Slot { + self.harness.chain.head().head_slot() + } + + pub(super) fn assert_head_slot(&self, slot: u64) { + assert_eq!(self.head_slot(), Slot::new(slot), "Unexpected head slot"); + } + + pub(super) fn max_known_slot(&self) -> Slot { + self.network_blocks_by_slot + .keys() + .max() + .copied() + .unwrap_or_default() + } + + pub(super) fn finalized_epoch(&self) -> types::Epoch { + self.harness + .chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch + } + + pub(super) fn assert_penalties(&self, expected_penalties: &[&'static str]) { + let penalties = self + .penalties + .iter() + .map(|penalty| penalty.msg) + .collect::<Vec<_>>(); + if penalties != expected_penalties { + panic!( + "Expected penalties: {:#?} but got {:#?}", + expected_penalties, + self.penalties + .iter() + .map(|p| format!("{} for peer {}", p.msg, p.peer_id)) + .collect::<Vec<_>>() + ); + } + } + + pub(super) fn assert_penalties_of_type(&self, expected_penalty: &'static str) { + if self.penalties.is_empty() { + panic!("No penalties but expected some of type {expected_penalty}"); + } + let non_matching_penalties = self + .penalties + .iter() + .filter(|penalty| penalty.msg != expected_penalty) + .collect::<Vec<_>>(); + if !non_matching_penalties.is_empty() { + panic!( + "Found non-matching penalties to {}: {:?}", + expected_penalty, non_matching_penalties + ); + } + } + + pub(super) fn assert_no_penalties(&mut self) { + if !self.penalties.is_empty() { + panic!("Some downscore events: {:?}", self.penalties); + } + } + fn assert_failed_lookup_sync(&mut self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!(self.completed_lookups(), 0, "some completed lookups"); + assert_eq!( + self.dropped_lookups(), + self.created_lookups(), + "not all dropped. Current lookups {:?}", + self.active_single_lookups(), + ); + self.assert_empty_network(); + self.assert_no_active_lookups(); + } + + fn assert_successful_lookup_sync(&mut self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!(self.dropped_lookups(), 0, "some dropped lookups"); + assert_eq!( + self.completed_lookups(), + self.created_lookups(), + "not all lookups completed. Current lookups {:?}", + self.active_single_lookups(), + ); + self.assert_empty_network(); + self.assert_no_active_lookups(); + } + + /// There is a lookup created with the block that triggers the unknown message that can't be + /// completed because it has zero peers + fn assert_successful_lookup_sync_parent_trigger(&mut self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!( + self.completed_lookups() + 1, + self.created_lookups(), + "all completed" + ); + assert_eq!(self.dropped_lookups(), 0, "some dropped lookups"); + self.assert_empty_network(); + } + + fn assert_pending_lookup_sync(&self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!(self.dropped_lookups(), 0, "some dropped lookups"); + assert_eq!(self.completed_lookups(), 0, "some completed lookups"); + } + + /// Assert there is at least one range sync chain created and that all sync chains completed + pub(super) fn assert_successful_range_sync(&self) { + assert!( + self.range_sync_chains_added() > 0, + "No created range sync chains" + ); + assert_eq!( + self.range_sync_chains_added(), + self.range_sync_chains_removed(), + "Not all chains completed" + ); + } + + fn lookup_at_slot(&self, slot: u64) -> &SeenLookup { + let block_root = self.block_root_at_slot(slot); + self.seen_lookups + .values() + .find(|lookup| lookup.block_root == block_root) + .unwrap_or_else(|| panic!("No lookup for block_root {block_root} of slot {slot}")) + } + + fn assert_peers_at_lookup_of_slot(&self, slot: u64, expected_peers: usize) { + let lookup = self.lookup_at_slot(slot); + if lookup.seen_peers.len() != expected_peers { + panic!( + "Expected lookup of slot {slot} to have {expected_peers} peers but had {:?}", + lookup.seen_peers + ) + } + } + + /// Total count of unique lookups created + fn created_lookups(&self) -> usize { + // Subtract initial value to allow resetting metrics mid test + self.sync_manager.block_lookups().metrics().created_lookups + - self.initial_block_lookups_metrics.created_lookups + } + + /// Total count of lookups completed or dropped + fn dropped_lookups(&self) -> usize { + // Subtract initial value to allow resetting metrics mid test + self.sync_manager.block_lookups().metrics().dropped_lookups + - self.initial_block_lookups_metrics.dropped_lookups + } + + fn completed_lookups(&self) -> usize { + // Subtract initial value to allow resetting metrics mid test + self.sync_manager + .block_lookups() + .metrics() + .completed_lookups + - self.initial_block_lookups_metrics.completed_lookups + } + + fn capture_metrics_baseline(&mut self) { + self.initial_block_lookups_metrics = self.sync_manager.block_lookups().metrics().clone() + } + + /// Returns the last lookup seen with matching block_root + fn lookup_by_root(&self, block_root: Hash256) -> &SeenLookup { + self.seen_lookups + .values() + .filter(|lookup| lookup.block_root == block_root) + .max_by_key(|lookup| lookup.id) + .unwrap_or_else(|| panic!("No loookup for block_root {block_root}")) + } + + fn range_sync_chains_added(&self) -> usize { + self.sync_manager.range_sync().metrics().chains_added + } + + fn range_sync_chains_removed(&self) -> usize { + self.sync_manager.range_sync().metrics().chains_removed + } + + fn custody_columns(&self) -> &[ColumnIndex] { + self.harness + .chain + .data_availability_checker + .custody_context() + .custody_columns_for_epoch(None, &self.harness.spec) + } + + // Test setup + + fn new_after_deneb() -> Option<Self> { + genesis_fork().deneb_enabled().then(Self::default) + } + + fn new_after_deneb_before_fulu() -> Option<Self> { + let fork = genesis_fork(); + if fork.deneb_enabled() && !fork.fulu_enabled() { + Some(Self::default()) } else { None } } - pub fn test_setup_after_fulu() -> Option<Self> { - let r = Self::test_setup(); - if r.fork_name.fulu_enabled() { - Some(r) - } else { - None - } + pub fn new_fulu_peer_test(fulu_test_type: FuluTestType) -> Option<Self> { + genesis_fork().fulu_enabled().then(|| { + Self::new(TestRigConfig { + fulu_test_type, + node_custody_type_override: None, + }) + }) } pub fn log(&self, msg: &str) { info!(msg, "TEST_RIG"); } - pub fn after_deneb(&self) -> bool { + pub fn is_after_deneb(&self) -> bool { self.fork_name.deneb_enabled() } - pub fn after_fulu(&self) -> bool { + pub fn is_after_fulu(&self) -> bool { self.fork_name.fulu_enabled() } @@ -167,8 +1465,16 @@ impl TestRig { self.send_sync_message(SyncMessage::UnknownParentBlock(peer_id, block, block_root)) } - fn trigger_unknown_parent_blob(&mut self, peer_id: PeerId, blob: BlobSidecar<E>) { - self.send_sync_message(SyncMessage::UnknownParentBlob(peer_id, blob.into())); + fn trigger_unknown_parent_blob(&mut self, peer_id: PeerId, blob: Arc<BlobSidecar<E>>) { + self.send_sync_message(SyncMessage::UnknownParentBlob(peer_id, blob)); + } + + fn trigger_unknown_parent_column( + &mut self, + peer_id: PeerId, + column: Arc<DataColumnSidecar<E>>, + ) { + self.send_sync_message(SyncMessage::UnknownParentDataColumn(peer_id, column)); } fn trigger_unknown_block_from_attestation(&mut self, block_root: Hash256, peer_id: PeerId) { @@ -177,13 +1483,6 @@ impl TestRig { )); } - /// Drain all sync messages in the sync_rx attached to the beacon processor - fn drain_sync_rx(&mut self) { - while let Ok(sync_message) = self.sync_rx.try_recv() { - self.send_sync_message(sync_message); - } - } - fn rand_block(&mut self) -> SignedBeaconBlock<E> { self.rand_block_and_blobs(NumBlobs::None).0 } @@ -197,105 +1496,36 @@ impl TestRig { generate_rand_block_and_blobs::<E>(fork_name, num_blobs, rng) } - fn rand_block_and_data_columns( - &mut self, - ) -> (SignedBeaconBlock<E>, Vec<Arc<DataColumnSidecar<E>>>) { - let num_blobs = NumBlobs::Number(1); - generate_rand_block_and_data_columns::<E>( - self.fork_name, - num_blobs, - &mut self.rng, - &self.harness.spec, - ) - } - - pub fn rand_block_and_parent( - &mut self, - ) -> (SignedBeaconBlock<E>, SignedBeaconBlock<E>, Hash256, Hash256) { - let parent = self.rand_block(); - let parent_root = parent.canonical_root(); - let mut block = self.rand_block(); - *block.message_mut().parent_root_mut() = parent_root; - let block_root = block.canonical_root(); - (parent, block, parent_root, block_root) - } - pub fn send_sync_message(&mut self, sync_message: SyncMessage<E>) { self.sync_manager.handle_message(sync_message); } + pub fn push_sync_message(&mut self, sync_message: SyncMessage<E>) { + self.sync_manager.send_sync_message(sync_message); + } + fn active_single_lookups(&self) -> Vec<BlockLookupSummary> { - self.sync_manager.active_single_lookups() + self.sync_manager.block_lookups().active_single_lookups() } fn active_single_lookups_count(&self) -> usize { - self.sync_manager.active_single_lookups().len() - } - - fn active_parent_lookups(&self) -> Vec<Vec<Hash256>> { - self.sync_manager.active_parent_lookups() - } - - fn active_parent_lookups_count(&self) -> usize { - self.sync_manager.active_parent_lookups().len() - } - - fn active_range_sync_chain(&self) -> (RangeSyncType, Slot, Slot) { - self.sync_manager.get_range_sync_chains().unwrap().unwrap() + self.active_single_lookups().len() } fn assert_single_lookups_count(&self, count: usize) { assert_eq!( self.active_single_lookups_count(), count, - "Unexpected count of single lookups. Current lookups: {:?}", + "Unexpected count of single lookups. Current lookups: {:#?}", self.active_single_lookups() ); } - fn assert_parent_lookups_count(&self, count: usize) { - assert_eq!( - self.active_parent_lookups_count(), - count, - "Unexpected count of parent lookups. Parent lookups: {:?}. Current lookups: {:?}", - self.active_parent_lookups(), - self.active_single_lookups() - ); - } - - fn assert_lookup_is_active(&self, block_root: Hash256) { - let lookups = self.sync_manager.active_single_lookups(); - if !lookups.iter().any(|l| l.1 == block_root) { - panic!("Expected lookup {block_root} to be the only active: {lookups:?}"); - } - } - - fn assert_lookup_peers(&self, block_root: Hash256, mut expected_peers: Vec<PeerId>) { - let mut lookup = self - .sync_manager - .active_single_lookups() - .into_iter() - .find(|l| l.1 == block_root) - .unwrap_or_else(|| panic!("no lookup for {block_root}")); - lookup.3.sort(); - expected_peers.sort(); - assert_eq!( - lookup.3, expected_peers, - "unexpected peers on lookup {block_root}" - ); - } - fn insert_ignored_chain(&mut self, block_root: Hash256) { + self.log(&format!("Inserting block in ignored chains {block_root:?}")); self.sync_manager.insert_ignored_chain(block_root); } - fn assert_not_ignored_chain(&mut self, chain_hash: Hash256) { - let chains = self.sync_manager.get_ignored_chains(); - if chains.contains(&chain_hash) { - panic!("ignored chains contain {chain_hash:?}: {chains:?}"); - } - } - fn assert_ignored_chain(&mut self, chain_hash: Hash256) { let chains = self.sync_manager.get_ignored_chains(); if !chains.contains(&chain_hash) { @@ -303,16 +1533,8 @@ impl TestRig { } } - fn find_single_lookup_for(&self, block_root: Hash256) -> Id { - self.active_single_lookups() - .iter() - .find(|l| l.1 == block_root) - .unwrap_or_else(|| panic!("no single block lookup found for {block_root}")) - .0 - } - #[track_caller] - fn expect_no_active_single_lookups(&self) { + fn assert_no_active_single_lookups(&self) { assert!( self.active_single_lookups().is_empty(), "expect no single block lookups: {:?}", @@ -321,13 +1543,8 @@ impl TestRig { } #[track_caller] - fn expect_no_active_lookups(&self) { - self.expect_no_active_single_lookups(); - } - - fn expect_no_active_lookups_empty_network(&mut self) { - self.expect_no_active_lookups(); - self.expect_empty_network(); + fn assert_no_active_lookups(&self) { + self.assert_no_active_single_lookups(); } pub fn new_connected_peer(&mut self) -> PeerId { @@ -337,367 +1554,62 @@ impl TestRig { .peers .write() .__add_connected_peer_testing_only(false, &self.harness.spec, key); - self.log(&format!("Added new peer for testing {peer_id:?}")); + + // Assumes custody subnet count == column count + let custody_subnets = self + .network_globals + .peers + .read() + .peer_info(&peer_id) + .expect("Peer should be known") + .custody_subnets_iter() + .copied() + .collect::<Vec<_>>(); + let peer_custody_str = + if custody_subnets.len() == self.harness.spec.number_of_custody_groups as usize { + "all".to_owned() + } else { + format!("{custody_subnets:?}") + }; + + self.log(&format!( + "Added new peer for testing {peer_id:?}, custody: {peer_custody_str}" + )); peer_id } pub fn new_connected_supernode_peer(&mut self) -> PeerId { let key = self.determinstic_key(); - self.network_globals + let peer_id = self + .network_globals .peers .write() - .__add_connected_peer_testing_only(true, &self.harness.spec, key) + .__add_connected_peer_testing_only(true, &self.harness.spec, key); + self.log(&format!( + "Added new peer for testing {peer_id:?}, custody: supernode" + )); + peer_id } fn determinstic_key(&mut self) -> CombinedKey { k256::ecdsa::SigningKey::random(&mut self.rng_08).into() } - pub fn new_connected_peers_for_peerdas(&mut self) { - // Enough sampling peers with few columns - for _ in 0..100 { - self.new_connected_peer(); - } - // One supernode peer to ensure all columns have at least one peer - self.new_connected_supernode_peer(); - } - - fn parent_chain_processed_success( - &mut self, - chain_hash: Hash256, - blocks: &[Arc<SignedBeaconBlock<E>>], - ) { - // Send import events for all pending parent blocks - for _ in blocks { - self.parent_block_processed_imported(chain_hash); - } - // Send final import event for the block that triggered the lookup - self.single_block_component_processed_imported(chain_hash); - } - - /// Locate a parent lookup chain with tip hash `chain_hash` - fn find_oldest_parent_lookup(&self, chain_hash: Hash256) -> Hash256 { - let parent_chain = self - .active_parent_lookups() - .into_iter() - .find(|chain| chain.first() == Some(&chain_hash)) - .unwrap_or_else(|| { - panic!( - "No parent chain with chain_hash {chain_hash:?}: Parent lookups {:?} Single lookups {:?}", - self.active_parent_lookups(), - self.active_single_lookups(), - ) - }); - *parent_chain.last().unwrap() - } - - fn parent_block_processed(&mut self, chain_hash: Hash256, result: BlockProcessingResult) { - let id = self.find_single_lookup_for(self.find_oldest_parent_lookup(chain_hash)); - self.single_block_component_processed(id, result); - } - - fn parent_blob_processed(&mut self, chain_hash: Hash256, result: BlockProcessingResult) { - let id = self.find_single_lookup_for(self.find_oldest_parent_lookup(chain_hash)); - self.single_blob_component_processed(id, result); - } - - fn parent_block_processed_imported(&mut self, chain_hash: Hash256) { - self.parent_block_processed( - chain_hash, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(chain_hash)), - ); - } - - fn single_block_component_processed(&mut self, id: Id, result: BlockProcessingResult) { - self.send_sync_message(SyncMessage::BlockComponentProcessed { - process_type: BlockProcessType::SingleBlock { id }, - result, - }) - } - - fn single_block_component_processed_imported(&mut self, block_root: Hash256) { - let id = self.find_single_lookup_for(block_root); - self.single_block_component_processed( - id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)), - ) - } - - fn single_blob_component_processed(&mut self, id: Id, result: BlockProcessingResult) { - self.send_sync_message(SyncMessage::BlockComponentProcessed { - process_type: BlockProcessType::SingleBlob { id }, - result, - }) - } - - fn parent_lookup_block_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - beacon_block: Option<Arc<SignedBeaconBlock<E>>>, - ) { - self.log("parent_lookup_block_response"); - self.send_sync_message(SyncMessage::RpcBlock { - sync_request_id: SyncRequestId::SingleBlock { id }, - peer_id, - beacon_block, - seen_timestamp: D, - }); - } - - fn single_lookup_block_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - beacon_block: Option<Arc<SignedBeaconBlock<E>>>, - ) { - self.log("single_lookup_block_response"); - self.send_sync_message(SyncMessage::RpcBlock { - sync_request_id: SyncRequestId::SingleBlock { id }, - peer_id, - beacon_block, - seen_timestamp: D, - }); - } - - fn parent_lookup_blob_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blob_sidecar: Option<Arc<BlobSidecar<E>>>, - ) { - self.log(&format!( - "parent_lookup_blob_response {:?}", - blob_sidecar.as_ref().map(|b| b.index) - )); - self.send_sync_message(SyncMessage::RpcBlob { - sync_request_id: SyncRequestId::SingleBlob { id }, - peer_id, - blob_sidecar, - seen_timestamp: D, - }); - } - - fn single_lookup_blob_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blob_sidecar: Option<Arc<BlobSidecar<E>>>, - ) { - self.send_sync_message(SyncMessage::RpcBlob { - sync_request_id: SyncRequestId::SingleBlob { id }, - peer_id, - blob_sidecar, - seen_timestamp: D, - }); - } - - fn complete_single_lookup_blob_download( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blobs: Vec<BlobSidecar<E>>, - ) { - for blob in blobs { - self.single_lookup_blob_response(id, peer_id, Some(blob.into())); - } - self.single_lookup_blob_response(id, peer_id, None); - } - - fn complete_single_lookup_blob_lookup_valid( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blobs: Vec<BlobSidecar<E>>, - import: bool, - ) { - let block_root = blobs.first().unwrap().block_root(); - let block_slot = blobs.first().unwrap().slot(); - self.complete_single_lookup_blob_download(id, peer_id, blobs); - self.expect_block_process(ResponseType::Blob); - self.single_blob_component_processed( - id.lookup_id, - if import { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - block_slot, block_root, - )) - }, - ); - } - - fn complete_lookup_block_download(&mut self, block: SignedBeaconBlock<E>) { - let block_root = block.canonical_root(); - let id = self.expect_block_lookup_request(block_root); - self.expect_empty_network(); - let peer_id = self.new_connected_peer(); - self.single_lookup_block_response(id, peer_id, Some(block.into())); - self.single_lookup_block_response(id, peer_id, None); - } - - fn complete_lookup_block_import_valid(&mut self, block_root: Hash256, import: bool) { - self.expect_block_process(ResponseType::Block); - let id = self.find_single_lookup_for(block_root); - self.single_block_component_processed( - id, - if import { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - Slot::new(0), - block_root, - )) - }, - ) - } - - fn complete_single_lookup_block_valid(&mut self, block: SignedBeaconBlock<E>, import: bool) { - let block_root = block.canonical_root(); - self.complete_lookup_block_download(block); - self.complete_lookup_block_import_valid(block_root, import) - } - - fn parent_lookup_failed(&mut self, id: SingleLookupReqId, peer_id: PeerId, error: RPCError) { - self.send_sync_message(SyncMessage::RpcError { - peer_id, - sync_request_id: SyncRequestId::SingleBlock { id }, - error, - }) - } - - fn parent_lookup_failed_unavailable(&mut self, id: SingleLookupReqId, peer_id: PeerId) { - self.parent_lookup_failed( - id, - peer_id, - RPCError::ErrorResponse( - RpcErrorResponse::ResourceUnavailable, - "older than deneb".into(), - ), - ); - } - - fn single_lookup_failed(&mut self, id: SingleLookupReqId, peer_id: PeerId, error: RPCError) { - self.send_sync_message(SyncMessage::RpcError { - peer_id, - sync_request_id: SyncRequestId::SingleBlock { id }, - error, - }) - } - - fn complete_valid_block_request( - &mut self, - id: SingleLookupReqId, - block: Arc<SignedBeaconBlock<E>>, - missing_components: bool, - ) { - // Complete download - let peer_id = PeerId::random(); - let slot = block.slot(); - let block_root = block.canonical_root(); - self.single_lookup_block_response(id, peer_id, Some(block)); - self.single_lookup_block_response(id, peer_id, None); - // Expect processing and resolve with import - self.expect_block_process(ResponseType::Block); - self.single_block_component_processed( - id.lookup_id, - if missing_components { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - slot, block_root, - )) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)) - }, - ) - } - - fn complete_valid_custody_request( - &mut self, - ids: DCByRootIds, - data_columns: Vec<Arc<DataColumnSidecar<E>>>, - missing_components: bool, - ) { - let lookup_id = if let SyncRequestId::DataColumnsByRoot(DataColumnsByRootRequestId { - requester: DataColumnsByRootRequester::Custody(id), - .. - }) = ids.first().unwrap().0 - { - id.requester.0.lookup_id - } else { - panic!("not a custody requester") - }; - - let first_column = data_columns.first().cloned().unwrap(); - - for id in ids { - self.log(&format!("return valid data column for {id:?}")); - let indices = &id.1; - let columns_to_send = indices - .iter() - .map(|&i| data_columns[i as usize].clone()) - .collect::<Vec<_>>(); - self.complete_data_columns_by_root_request(id, &columns_to_send); - } - - // Expect work event - self.expect_rpc_custody_column_work_event(); - - // Respond with valid result - self.send_sync_message(SyncMessage::BlockComponentProcessed { - process_type: BlockProcessType::SingleCustodyColumn(lookup_id), - result: if missing_components { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - first_column.slot(), - first_column.block_root(), - )) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported( - first_column.block_root(), - )) - }, - }); - } - - fn complete_data_columns_by_root_request( - &mut self, - (sync_request_id, _): DCByRootId, - data_columns: &[Arc<DataColumnSidecar<E>>], - ) { - let peer_id = PeerId::random(); - for data_column in data_columns { - // Send chunks - self.send_sync_message(SyncMessage::RpcDataColumn { - sync_request_id, - peer_id, - data_column: Some(data_column.clone()), - seen_timestamp: timestamp_now(), - }); - } - // Send stream termination - self.send_sync_message(SyncMessage::RpcDataColumn { - sync_request_id, - peer_id, - data_column: None, - seen_timestamp: timestamp_now(), - }); - } - - /// Return RPCErrors for all active requests of peer - fn rpc_error_all_active_requests(&mut self, disconnected_peer_id: PeerId) { - self.drain_network_rx(); - while let Ok(sync_request_id) = self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - app_request_id: AppRequestId::Sync(id), - .. - } if *peer_id == disconnected_peer_id => Some(*id), - _ => None, - }) { - self.send_sync_message(SyncMessage::RpcError { - peer_id: disconnected_peer_id, - sync_request_id, - error: RPCError::Disconnected, - }); + pub fn new_connected_peers_for_peerdas(&mut self) -> Vec<PeerId> { + match self.fulu_test_type.them_node_custody_type() { + NodeCustodyType::Fullnode => { + // Enough sampling peers with few columns + let mut peers = (0..100) + .map(|_| self.new_connected_peer()) + .collect::<Vec<_>>(); + // One supernode peer to ensure all columns have at least one peer + peers.push(self.new_connected_supernode_peer()); + peers + } + NodeCustodyType::Supernode | NodeCustodyType::SemiSupernode => { + let peer = self.new_connected_supernode_peer(); + vec![peer] + } } } @@ -705,6 +1617,22 @@ impl TestRig { self.send_sync_message(SyncMessage::Disconnect(peer_id)); } + fn get_connected_peers(&self) -> Vec<PeerId> { + self.network_globals + .peers + .read() + .peers() + .map(|(peer, _)| *peer) + .collect::<Vec<_>>() + } + + fn disconnect_all_peers(&mut self) { + for peer in self.get_connected_peers() { + self.log(&format!("Disconnecting peer {peer}")); + self.send_sync_message(SyncMessage::Disconnect(peer)); + } + } + fn drain_network_rx(&mut self) { while let Ok(event) = self.network_rx.try_recv() { self.network_rx_queue.push(event); @@ -737,6 +1665,7 @@ impl TestRig { } } + #[allow(dead_code)] pub fn pop_received_processor_event<T, F: Fn(&WorkEvent<E>) -> Option<T>>( &mut self, predicate_transform: F, @@ -761,7 +1690,7 @@ impl TestRig { } } - pub fn expect_empty_processor(&mut self) { + pub fn assert_empty_processor(&mut self) { self.drain_processor_rx(); if !self.beacon_processor_rx_queue.is_empty() { panic!( @@ -771,215 +1700,8 @@ impl TestRig { } } - fn find_block_lookup_request( - &mut self, - for_block: Hash256, - ) -> Result<SingleLookupReqId, String> { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlocksByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlock { id }), - } if request.block_roots().to_vec().contains(&for_block) => Some(*id), - _ => None, - }) - } - #[track_caller] - fn expect_block_lookup_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.find_block_lookup_request(for_block) - .unwrap_or_else(|e| panic!("Expected block request for {for_block:?}: {e}")) - } - - fn find_blob_lookup_request( - &mut self, - for_block: Hash256, - ) -> Result<SingleLookupReqId, String> { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlobsByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlob { id }), - } if request - .blob_ids - .to_vec() - .iter() - .any(|r| r.block_root == for_block) => - { - Some(*id) - } - _ => None, - }) - } - - #[track_caller] - fn expect_blob_lookup_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.find_blob_lookup_request(for_block) - .unwrap_or_else(|e| panic!("Expected blob request for {for_block:?}: {e}")) - } - - #[track_caller] - fn expect_block_parent_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlocksByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlock { id }), - } if request.block_roots().to_vec().contains(&for_block) => Some(*id), - _ => None, - }) - .unwrap_or_else(|e| panic!("Expected block parent request for {for_block:?}: {e}")) - } - - fn expect_no_requests_for(&mut self, block_root: Hash256) { - if let Ok(request) = self.find_block_lookup_request(block_root) { - panic!("Expected no block request for {block_root:?} found {request:?}"); - } - if let Ok(request) = self.find_blob_lookup_request(block_root) { - panic!("Expected no blob request for {block_root:?} found {request:?}"); - } - } - - #[track_caller] - fn expect_blob_parent_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlobsByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlob { id }), - } if request - .blob_ids - .to_vec() - .iter() - .all(|r| r.block_root == for_block) => - { - Some(*id) - } - _ => None, - }) - .unwrap_or_else(|e| panic!("Expected blob parent request for {for_block:?}: {e}")) - } - - /// Retrieves an unknown number of requests for data columns of `block_root`. Because peer ENRs - /// are random, and peer selection is random, the total number of batched requests is unknown. - fn expect_data_columns_by_root_requests( - &mut self, - block_root: Hash256, - count: usize, - ) -> DCByRootIds { - let mut requests: DCByRootIds = vec![]; - loop { - let req = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::DataColumnsByRoot(request), - app_request_id: - AppRequestId::Sync(id @ SyncRequestId::DataColumnsByRoot { .. }), - } => { - let matching = request - .data_column_ids - .iter() - .find(|id| id.block_root == block_root)?; - - let indices = matching.columns.iter().copied().collect(); - Some((*id, indices)) - } - _ => None, - }) - .unwrap_or_else(|e| { - panic!("Expected more DataColumnsByRoot requests for {block_root:?}: {e}") - }); - requests.push(req); - - // Should never infinite loop because sync does not send requests for 0 columns - if requests.iter().map(|r| r.1.len()).sum::<usize>() >= count { - return requests; - } - } - } - - fn expect_only_data_columns_by_root_requests( - &mut self, - for_block: Hash256, - count: usize, - ) -> DCByRootIds { - let ids = self.expect_data_columns_by_root_requests(for_block, count); - self.expect_empty_network(); - ids - } - - #[track_caller] - fn expect_block_process(&mut self, response_type: ResponseType) { - match response_type { - ResponseType::Block => self - .pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::RpcBlock).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected block work event: {e}")), - ResponseType::Blob => self - .pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::RpcBlobs).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected blobs work event: {e}")), - ResponseType::CustodyColumn => self - .pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::RpcCustodyColumn).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected column work event: {e}")), - } - } - - fn expect_rpc_custody_column_work_event(&mut self) { - self.pop_received_processor_event(|ev| { - if ev.work_type() == beacon_processor::WorkType::RpcCustodyColumn { - Some(()) - } else { - None - } - }) - .unwrap_or_else(|e| panic!("Expected RPC custody column work: {e}")) - } - - #[allow(dead_code)] - fn expect_no_work_event(&mut self) { - self.drain_processor_rx(); - assert!(self.network_rx_queue.is_empty()); - } - - fn expect_no_penalty_for(&mut self, peer_id: PeerId) { - self.drain_network_rx(); - let downscore_events = self - .network_rx_queue - .iter() - .filter_map(|ev| match ev { - NetworkMessage::ReportPeer { - peer_id: p_id, msg, .. - } if p_id == &peer_id => Some(msg), - _ => None, - }) - .collect::<Vec<_>>(); - if !downscore_events.is_empty() { - panic!("Some downscore events for {peer_id}: {downscore_events:?}"); - } - } - - #[track_caller] - fn expect_parent_chain_process(&mut self) { - match self.beacon_processor_rx.try_recv() { - Ok(work) => { - // Parent chain sends blocks one by one - assert_eq!(work.work_type(), beacon_processor::WorkType::RpcBlock); - } - other => panic!( - "Expected rpc_block from chain segment process, found {:?}", - other - ), - } - } - - #[track_caller] - pub fn expect_empty_network(&mut self) { + pub fn assert_empty_network(&mut self) { self.drain_network_rx(); if !self.network_rx_queue.is_empty() { let n = self.network_rx_queue.len(); @@ -990,115 +1712,51 @@ impl TestRig { } } - #[track_caller] - fn expect_empty_beacon_processor(&mut self) { - match self.beacon_processor_rx.try_recv() { - Err(mpsc::error::TryRecvError::Empty) => {} // ok - Ok(event) => panic!("expected empty beacon processor: {:?}", event), - other => panic!("unexpected err {:?}", other), - } - } - - #[track_caller] - pub fn expect_penalty(&mut self, peer_id: PeerId, expect_penalty_msg: &'static str) { - let penalty_msg = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::ReportPeer { - peer_id: p_id, msg, .. - } if p_id == &peer_id => Some(msg.to_owned()), - _ => None, - }) - .unwrap_or_else(|_| { - panic!( - "Expected '{expect_penalty_msg}' penalty for peer {peer_id}: {:#?}", - self.network_rx_queue - ) - }); - assert_eq!( - penalty_msg, expect_penalty_msg, - "Unexpected penalty msg for {peer_id}" - ); - self.log(&format!("Found expected penalty {penalty_msg}")); - } - - pub fn block_with_parent_and_blobs( + async fn import_block_to_da_checker( &mut self, - parent_root: Hash256, - num_blobs: NumBlobs, - ) -> (SignedBeaconBlock<E>, Vec<BlobSidecar<E>>) { - let (mut block, mut blobs) = self.rand_block_and_blobs(num_blobs); - *block.message_mut().parent_root_mut() = parent_root; - blobs.iter_mut().for_each(|blob| { - blob.signed_block_header = block.signed_block_header(); - }); - (block, blobs) - } - - pub fn rand_blockchain(&mut self, depth: usize) -> Vec<Arc<SignedBeaconBlock<E>>> { - let mut blocks = Vec::<Arc<SignedBeaconBlock<E>>>::with_capacity(depth); - for slot in 0..depth { - let parent = blocks - .last() - .map(|b| b.canonical_root()) - .unwrap_or_else(Hash256::random); - let mut block = self.rand_block(); - *block.message_mut().parent_root_mut() = parent; - *block.message_mut().slot_mut() = slot.into(); - blocks.push(block.into()); - } - self.log(&format!( - "Blockchain dump {:#?}", - blocks - .iter() - .map(|b| format!( - "block {} {} parent {}", - b.slot(), - b.canonical_root(), - b.parent_root() - )) - .collect::<Vec<_>>() - )); - blocks - } - - fn insert_block_to_da_checker(&mut self, block: Arc<SignedBeaconBlock<E>>) { - let state = BeaconState::Base(BeaconStateBase::random_for_test(&mut self.rng)); - let parent_block = self.rand_block(); - let import_data = BlockImportData::<E>::__new_for_test( - block.canonical_root(), - state, - parent_block.into(), - ); - let payload_verification_outcome = PayloadVerificationOutcome { - payload_verification_status: PayloadVerificationStatus::Verified, - is_valid_merge_transition_block: false, - }; - let executed_block = - AvailabilityPendingExecutedBlock::new(block, import_data, payload_verification_outcome); - match self - .harness + block: Arc<SignedBeaconBlock<E>>, + ) -> AvailabilityProcessingStatus { + // Simulate importing block from another source. Don't use GossipVerified as it checks with + // the clock, which does not match the timestamp in the payload. + let lookup_block = LookupBlock::new(block); + self.harness .chain - .data_availability_checker - .put_executed_block(executed_block) - .unwrap() - { - Availability::Available(_) => panic!("block removed from da_checker, available"), - Availability::MissingComponents(block_root) => { + .process_block( + lookup_block.block_root(), + lookup_block, + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await + .expect("Error processing block") + } + + async fn insert_block_to_da_chain_and_assert_missing_componens( + &mut self, + block: Arc<SignedBeaconBlock<E>>, + ) { + match self.import_block_to_da_checker(block).await { + AvailabilityProcessingStatus::Imported(_) => { + panic!("block removed from da_checker, available") + } + AvailabilityProcessingStatus::MissingComponents(_, block_root) => { self.log(&format!("inserted block to da_checker {block_root:?}")) } - }; + } } - fn insert_blob_to_da_checker(&mut self, blob: BlobSidecar<E>) { + fn insert_blob_to_da_checker(&mut self, blob: Arc<BlobSidecar<E>>) { match self .harness .chain .data_availability_checker - .put_gossip_verified_blobs( + .put_kzg_verified_blobs( blob.block_root(), - std::iter::once(GossipVerifiedBlob::<_, Observe>::__assumed_valid( - blob.into(), - )), + std::iter::once( + KzgVerifiedBlob::new(blob, &self.harness.chain.kzg, Duration::new(0, 0)) + .expect("Invalid blob"), + ), ) .unwrap() { @@ -1109,7 +1767,11 @@ impl TestRig { }; } - fn insert_block_to_availability_cache(&mut self, block: Arc<SignedBeaconBlock<E>>) { + fn insert_block_to_da_checker_as_pre_execution(&mut self, block: Arc<SignedBeaconBlock<E>>) { + self.log(&format!( + "Inserting block to availability_cache as pre_execution_block {:?}", + block.canonical_root() + )); self.harness .chain .data_availability_checker @@ -1118,6 +1780,9 @@ impl TestRig { } fn simulate_block_gossip_processing_becomes_invalid(&mut self, block_root: Hash256) { + self.log(&format!( + "Marking block {block_root:?} in da_checker as execution error" + )); self.harness .chain .data_availability_checker @@ -1129,19 +1794,38 @@ impl TestRig { }); } - fn simulate_block_gossip_processing_becomes_valid_missing_components( + async fn simulate_block_gossip_processing_becomes_valid( &mut self, block: Arc<SignedBeaconBlock<E>>, ) { let block_root = block.canonical_root(); - self.insert_block_to_da_checker(block); + match self.import_block_to_da_checker(block).await { + AvailabilityProcessingStatus::Imported(block_root) => { + self.log(&format!( + "insert block to da_checker and it imported {block_root:?}" + )); + } + AvailabilityProcessingStatus::MissingComponents(_, _) => { + panic!("block not imported after adding to da_checker"); + } + } self.send_sync_message(SyncMessage::GossipBlockProcessResult { block_root, imported: false, }); } + + fn requests_count(&self) -> HashMap<&'static str, usize> { + let mut requests_count = HashMap::new(); + for (request, _) in &self.requests { + *requests_count + .entry(Into::<&'static str>::into(request)) + .or_default() += 1; + } + requests_count + } } #[test] @@ -1158,1561 +1842,800 @@ fn stable_rng() { ); } -#[test] -fn test_single_block_lookup_happy_path() { - let mut rig = TestRig::test_setup(); - let block = rig.rand_block(); - let peer_id = rig.new_connected_peer(); - let block_root = block.canonical_root(); - // Trigger the request - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - let id = rig.expect_block_lookup_request(block_root); +macro_rules! run_lookups_tests_for_depths { + ($($depth:literal),+ $(,)?) => { + paste::paste! { + $( + #[tokio::test] + async fn [<happy_path_unknown_attestation_depth_ $depth>]() { + happy_path_unknown_attestation($depth).await; + } - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - rig.single_lookup_block_response(id, peer_id, Some(block.into())); - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); + #[tokio::test] + async fn [<happy_path_unknown_block_parent_depth_ $depth>]() { + happy_path_unknown_block_parent($depth).await; + } - // The request should still be active. - assert_eq!(rig.active_single_lookups_count(), 1); + #[tokio::test] + async fn [<happy_path_unknown_data_parent_depth_ $depth>]() { + happy_path_unknown_data_parent($depth).await; + } - // Send the stream termination. Peer should have not been penalized, and the request removed - // after processing. - rig.single_lookup_block_response(id, peer_id, None); - rig.single_block_component_processed_imported(block_root); - rig.expect_empty_network(); - rig.expect_no_active_lookups(); + #[tokio::test] + async fn [<happy_path_multiple_triggers_depth_ $depth>]() { + happy_path_multiple_triggers($depth).await; + } + + #[tokio::test] + async fn [<bad_peer_empty_block_response_depth_ $depth>]() { + bad_peer_empty_block_response($depth).await; + } + + #[tokio::test] + async fn [<bad_peer_empty_data_response_depth_ $depth>]() { + bad_peer_empty_data_response($depth).await; + } + + #[tokio::test] + async fn [<bad_peer_too_few_data_response_depth_ $depth>]() { + bad_peer_too_few_data_response($depth).await; + } + + #[tokio::test] + async fn [<bad_peer_wrong_block_response_depth_ $depth>]() { + bad_peer_wrong_block_response($depth).await; + } + + #[tokio::test] + async fn [<bad_peer_wrong_data_response_depth_ $depth>]() { + bad_peer_wrong_data_response($depth).await; + } + + #[tokio::test] + async fn [<bad_peer_rpc_failure_depth_ $depth>]() { + bad_peer_rpc_failure($depth).await; + } + + #[tokio::test] + async fn [<too_many_download_failures_depth_ $depth>]() { + too_many_download_failures($depth).await; + } + + #[tokio::test] + async fn [<too_many_processing_failures_depth_ $depth>]() { + too_many_processing_failures($depth).await; + } + + #[tokio::test] + async fn [<peer_disconnected_then_rpc_error_depth_ $depth>]() { + peer_disconnected_then_rpc_error($depth).await; + } + )+ + } + }; } -// Tests that if a peer does not respond with a block, we downscore and retry the block only -#[test] -fn test_single_block_lookup_empty_response() { - let mut r = TestRig::test_setup(); +run_lookups_tests_for_depths!(1, 2); - let block = r.rand_block(); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - - // Trigger the request - r.trigger_unknown_block_from_attestation(block_root, peer_id); - let id = r.expect_block_lookup_request(block_root); - - // The peer does not have the block. It should be penalized. - r.single_lookup_block_response(id, peer_id, None); - r.expect_penalty(peer_id, "NotEnoughResponsesReturned"); - // it should be retried - let id = r.expect_block_lookup_request(block_root); - // Send the right block this time. - r.single_lookup_block_response(id, peer_id, Some(block.into())); - r.expect_block_process(ResponseType::Block); - r.single_block_component_processed_imported(block_root); - r.expect_no_active_lookups(); +/// Assert that lookup sync succeeds with the happy case +async fn happy_path_unknown_attestation(depth: usize) { + let mut r = TestRig::default(); + // We get attestation for a block descendant (depth) blocks of current head + r.build_chain_and_trigger_last_block(depth).await; + // Complete the request with good peer behaviour + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); } -#[test] -fn test_single_block_lookup_wrong_response() { - let mut rig = TestRig::test_setup(); - - let block_hash = Hash256::random(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_block_from_attestation(block_hash, peer_id); - let id = rig.expect_block_lookup_request(block_hash); - - // Peer sends something else. It should be penalized. - let bad_block = rig.rand_block(); - rig.single_lookup_block_response(id, peer_id, Some(bad_block.into())); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - rig.expect_block_lookup_request(block_hash); // should be retried - - // Send the stream termination. This should not produce an additional penalty. - rig.single_lookup_block_response(id, peer_id, None); - rig.expect_empty_network(); +async fn happy_path_unknown_block_parent(depth: usize) { + let mut r = TestRig::default(); + r.build_chain(depth).await; + r.trigger_with_last_unknown_block_parent(); + r.simulate(SimulateConfig::happy_path()).await; + // All lookups should NOT complete on this test, however note the following for the tip lookup, + // it's the lookup for the tip block which has 0 peers and a block cached: + // - before deneb the block is cached, so it's sent for processing, and success + // - before fulu the block is cached, but we can't fetch blobs so it's stuck + // - after fulu the block is cached, we start a custody request and since we use the global pool + // of peers we DO have 1 connected synced supernode peer, which gives us the columns and the + // lookup succeeds + if r.is_after_deneb() && !r.is_after_fulu() { + r.assert_successful_lookup_sync_parent_trigger() + } else { + r.assert_successful_lookup_sync(); + } } -#[test] -fn test_single_block_lookup_failure() { - let mut rig = TestRig::test_setup(); - - let block_hash = Hash256::random(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_block_from_attestation(block_hash, peer_id); - let id = rig.expect_block_lookup_request(block_hash); - - // The request fails. RPC failures are handled elsewhere so we should not penalize the peer. - rig.single_lookup_failed(id, peer_id, RPCError::UnsupportedProtocol); - rig.expect_block_lookup_request(block_hash); - rig.expect_empty_network(); +/// Assert that sync completes from a GossipUnknownParentBlob / UnknownDataColumnParent +async fn happy_path_unknown_data_parent(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain(depth).await; + if r.is_after_fulu() { + r.trigger_with_last_unknown_data_column_parent(); + } else if r.is_after_deneb() { + r.trigger_with_last_unknown_blob_parent(); + } + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync_parent_trigger(); } -#[test] -fn test_single_block_lookup_peer_disconnected_then_rpc_error() { - let mut rig = TestRig::test_setup(); +/// Assert that multiple trigger types don't create extra lookups +async fn happy_path_multiple_triggers(depth: usize) { + let mut r = TestRig::default(); + // + 1, because the unknown parent trigger needs two new blocks + r.build_chain(depth + 1).await; + r.trigger_with_last_block(); + r.trigger_with_last_block(); + r.trigger_with_last_unknown_block_parent(); + r.trigger_with_last_unknown_block_parent(); + if r.is_after_fulu() { + r.trigger_with_last_unknown_data_column_parent(); + } else if r.is_after_deneb() { + r.trigger_with_last_unknown_blob_parent(); + } + r.simulate(SimulateConfig::happy_path()).await; + assert_eq!(r.created_lookups(), depth + 1, "Don't create extra lookups"); + r.assert_successful_lookup_sync(); +} - let block_hash = Hash256::random(); - let peer_id = rig.new_connected_peer(); +// Test bad behaviour of peers - // Trigger the request. - rig.trigger_unknown_block_from_attestation(block_hash, peer_id); - let id = rig.expect_block_lookup_request(block_hash); +/// Assert that if peer responds with no blocks, we downscore, and retry the same lookup +async fn bad_peer_empty_block_response(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + // Simulate that peer returns empty response once, then good behaviour + r.simulate(SimulateConfig::new().return_no_blocks_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["NotEnoughResponsesReturned"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) For post-deneb assert that the blobs are not re-fetched + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with no blobs / columns, we downscore, and retry the same lookup +async fn bad_peer_empty_data_response(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_no_data_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["NotEnoughResponsesReturned"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with not enough blobs / columns, we downscore, and retry the same +/// lookup +async fn bad_peer_too_few_data_response(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_too_few_data_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["NotEnoughResponsesReturned"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with bad blocks, we downscore, and retry the same lookup +async fn bad_peer_wrong_block_response(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_wrong_blocks_once()) + .await; + r.assert_penalties(&["UnrequestedBlockRoot"]); + r.assert_successful_lookup_sync(); + + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with bad blobs / columns, we downscore, and retry the same lookup +async fn bad_peer_wrong_data_response(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_wrong_sidecar_for_block_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["UnrequestedBlockRoot"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that on network error, we DON'T downscore, and retry the same lookup +async fn bad_peer_rpc_failure(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::UnsupportedProtocol)) + .await; + r.assert_no_penalties(); + r.assert_successful_lookup_sync(); +} + +// Test retry logic + +/// Assert that on too many download failures the lookup fails, but we can still sync +async fn too_many_download_failures(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + // Simulate that a peer always returns empty + r.simulate(SimulateConfig::new().return_no_blocks_always()) + .await; + // We register multiple penalties, the lookup fails and sync does not progress + r.assert_penalties_of_type("NotEnoughResponsesReturned"); + r.assert_failed_lookup_sync(); + + // Trigger sync again for same block, and complete successfully. + // Asserts that the lookup is not on a blacklist + r.capture_metrics_baseline(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); +} + +/// Assert that on too many processing failures the lookup fails, but we can still sync +async fn too_many_processing_failures(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + // Simulate that a peer always returns empty + r.simulate( + SimulateConfig::new() + .with_process_result(|| BlockProcessingResult::Err(BlockError::BlockSlotLimitReached)), + ) + .await; + // We register multiple penalties, the lookup fails and sync does not progress + r.assert_penalties_of_type("lookup_block_processing_failure"); + r.assert_failed_lookup_sync(); + + // Trigger sync again for same block, and complete successfully. + // Asserts that the lookup is not on a blacklist + r.capture_metrics_baseline(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); +} + +#[tokio::test] +/// Assert that multiple trigger types don't create extra lookups +async fn unknown_parent_does_not_add_peers_to_itself() { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + // 2, because the unknown parent trigger needs two new blocks + r.build_chain(2).await; + r.trigger_with_last_unknown_block_parent(); + r.trigger_with_last_unknown_block_parent(); + if r.is_after_fulu() { + r.trigger_with_last_unknown_data_column_parent(); + } else if r.is_after_deneb() { + r.trigger_with_last_unknown_blob_parent(); + } + r.simulate(SimulateConfig::happy_path()).await; + r.assert_peers_at_lookup_of_slot(2, 0); + r.assert_peers_at_lookup_of_slot(1, 3); + assert_eq!(r.created_lookups(), 2, "Don't create extra lookups"); + // All lookups should NOT complete on this test, however note the following for the tip lookup, + // it's the lookup for the tip block which has 0 peers and a block cached: + // - before fulu the block is cached, but we can't fetch blobs so it's stuck + // - after fulu the block is cached, we start a custody request and since we use the global pool + // of peers we DO have >1 connected synced supernode peer, which gives us the columns and the + // lookup succeeds + if r.is_after_fulu() { + r.assert_successful_lookup_sync() + } else { + r.assert_successful_lookup_sync_parent_trigger(); + } +} + +#[tokio::test] +/// Assert that if the beacon processor returns Ignored, the lookup is dropped +async fn test_single_block_lookup_ignored_response() { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(1).await; + // Send an Ignored response, the request should be dropped + r.simulate(SimulateConfig::new().with_process_result(|| BlockProcessingResult::Ignored)) + .await; + // The block was not actually imported + r.assert_head_slot(0); + assert_eq!(r.created_lookups(), 1, "no created lookups"); + assert_eq!(r.dropped_lookups(), 1, "no dropped lookups"); + assert_eq!(r.completed_lookups(), 0, "some completed lookups"); +} + +#[tokio::test] +/// Assert that if the beacon processor returns DuplicateFullyImported, the lookup completes successfully +async fn test_single_block_lookup_duplicate_response() { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(1).await; + // Send a DuplicateFullyImported response, the lookup should complete successfully + r.simulate(SimulateConfig::new().with_process_result(|| { + BlockProcessingResult::Err(BlockError::DuplicateFullyImported(Hash256::ZERO)) + })) + .await; + // The block was not actually imported + r.assert_head_slot(0); + r.assert_successful_lookup_sync(); +} + +/// Assert that when peers disconnect the lookups are not dropped (kept with zero peers) +async fn peer_disconnected_then_rpc_error(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + r.assert_single_lookups_count(1); // The peer disconnect event reaches sync before the rpc error. - rig.peer_disconnected(peer_id); + r.disconnect_all_peers(); // The lookup is not removed as it can still potentially make progress. - rig.assert_single_lookups_count(1); - // The request fails. - rig.single_lookup_failed(id, peer_id, RPCError::Disconnected); - rig.expect_block_lookup_request(block_hash); - // The request should be removed from the network context on disconnection. - rig.expect_empty_network(); + r.assert_single_lookups_count(1); + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::Disconnected)) + .await; + + // Regardless of depth, only the initial lookup is created, because the peer disconnects before + // being able to download the block + assert_eq!(r.created_lookups(), 1, "no created lookups"); + assert_eq!(r.completed_lookups(), 0, "some completed lookups"); + assert_eq!(r.dropped_lookups(), 0, "some dropped lookups"); + r.assert_empty_network(); + r.assert_single_lookups_count(1); } -#[test] -fn test_single_block_lookup_becomes_parent_request() { - let mut rig = TestRig::test_setup(); - - let block = Arc::new(rig.rand_block()); - let block_root = block.canonical_root(); - let parent_root = block.parent_root(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_block_from_attestation(block.canonical_root(), peer_id); - let id = rig.expect_block_parent_request(block_root); - - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - rig.single_lookup_block_response(id, peer_id, Some(block.clone())); - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); - - // The request should still be active. - assert_eq!(rig.active_single_lookups_count(), 1); - - // Send the stream termination. Peer should have not been penalized, and the request moved to a - // parent request after processing. - rig.single_block_component_processed( - id.lookup_id, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ); - assert_eq!(rig.active_single_lookups_count(), 2); // 2 = current + parent - rig.expect_block_parent_request(parent_root); - rig.expect_empty_network(); - assert_eq!(rig.active_parent_lookups_count(), 1); -} - -#[test] -fn test_parent_lookup_happy_path() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - let id = rig.expect_block_parent_request(parent_root); - - // Peer sends the right block, it should be sent for processing. Peer should not be penalized. - rig.parent_lookup_block_response(id, peer_id, Some(parent.into())); - // No request of blobs because the block has not data - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); - rig.expect_empty_network(); - - // Add peer to child lookup to prevent it being dropped - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - // Processing succeeds, now the rest of the chain should be sent for processing. - rig.parent_block_processed( - block_root, - BlockError::DuplicateFullyImported(block_root).into(), - ); - rig.expect_parent_chain_process(); - rig.parent_chain_processed_success(block_root, &[]); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_wrong_response() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - let id1 = rig.expect_block_parent_request(parent_root); - - // Peer sends the wrong block, peer should be penalized and the block re-requested. - let bad_block = rig.rand_block(); - rig.parent_lookup_block_response(id1, peer_id, Some(bad_block.into())); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - let id2 = rig.expect_block_parent_request(parent_root); - - // Send the stream termination for the first request. This should not produce extra penalties. - rig.parent_lookup_block_response(id1, peer_id, None); - rig.expect_empty_network(); - - // Send the right block this time. - rig.parent_lookup_block_response(id2, peer_id, Some(parent.into())); - rig.expect_block_process(ResponseType::Block); - - // Add peer to child lookup to prevent it being dropped - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - // Processing succeeds, now the rest of the chain should be sent for processing. - rig.parent_block_processed_imported(block_root); - rig.expect_parent_chain_process(); - rig.parent_chain_processed_success(block_root, &[]); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_rpc_failure() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - let id = rig.expect_block_parent_request(parent_root); - - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - let id = rig.expect_block_parent_request(parent_root); - - // Send the right block this time. - rig.parent_lookup_block_response(id, peer_id, Some(parent.into())); - rig.expect_block_process(ResponseType::Block); - - // Add peer to child lookup to prevent it being dropped - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - // Processing succeeds, now the rest of the chain should be sent for processing. - rig.parent_block_processed_imported(block_root); - rig.expect_parent_chain_process(); - rig.parent_chain_processed_success(block_root, &[]); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_too_many_attempts() { - let mut rig = TestRig::test_setup(); - - let block = rig.rand_block(); - let parent_root = block.parent_root(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - for i in 1..=PARENT_FAIL_TOLERANCE { - let id = rig.expect_block_parent_request(parent_root); - // Blobs are only requested in the first iteration as this test only retries blocks - - if i % 2 == 0 { - // make sure every error is accounted for - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - } else { - // Send a bad block this time. It should be tried again. - let bad_block = rig.rand_block(); - rig.parent_lookup_block_response(id, peer_id, Some(bad_block.into())); - // Send the stream termination - - // Note, previously we would send the same lookup id with a stream terminator, - // we'd ignore it because we'd intrepret it as an unrequested response, since - // we already got one response for the block. I'm not sure what the intent is - // for having this stream terminator line in this test at all. Receiving an invalid - // block and a stream terminator with the same Id now results in two failed attempts, - // I'm unsure if this is how it should behave? - // - rig.parent_lookup_block_response(id, peer_id, None); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - } +#[tokio::test] +/// Assert that when creating multiple lookups their parent-child relation is discovered and we add +/// peers recursively from child to parent. +async fn lookups_form_chain() { + let depth = 5; + let mut r = TestRig::default(); + r.build_chain(depth).await; + for slot in (1..=depth).rev() { + r.trigger_with_block_at_slot(slot as u64); } + // TODO(tree-sync): Assert that there are `depth` disjoint chains + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); - rig.expect_no_active_lookups_empty_network(); + // Assert that the peers are added to ancestor lookups, + // - The lookup with max slot has 1 peer + // - The lookup with min slot has all the peers + for slot in 1..=(depth as u64) { + let lookup = r.lookup_by_root(r.block_root_at_slot(slot)); + assert_eq!( + lookup.seen_peers.len(), + 1 + depth - slot as usize, + "Unexpected peer count for lookup at slot {slot}" + ); + } } -#[test] -fn test_parent_lookup_too_many_download_attempts_no_blacklist() { - let mut rig = TestRig::test_setup(); +#[tokio::test] +/// Assert that if a lookup chain (by appending ancestors) is too long we drop it +async fn test_parent_lookup_too_deep_grow_ancestor_one() { + let mut r = TestRig::default(); + r.build_chain(PARENT_DEPTH_TOLERANCE + 1).await; + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - for i in 1..=PARENT_FAIL_TOLERANCE { - rig.assert_not_ignored_chain(block_root); - let id = rig.expect_block_parent_request(parent_root); - if i % 2 != 0 { - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - } else { - // Send a bad block this time. It should be tried again. - let bad_block = rig.rand_block(); - rig.parent_lookup_block_response(id, peer_id, Some(bad_block.into())); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - } - } - - rig.assert_not_ignored_chain(block_root); - rig.assert_not_ignored_chain(parent.canonical_root()); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_too_many_processing_attempts_must_blacklist() { - const PROCESSING_FAILURES: u8 = PARENT_FAIL_TOLERANCE / 2 + 1; - let mut rig = TestRig::test_setup(); - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - - rig.log("Fail downloading the block"); - for _ in 0..(PARENT_FAIL_TOLERANCE - PROCESSING_FAILURES) { - let id = rig.expect_block_parent_request(parent_root); - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - } - - rig.log("Now fail processing a block in the parent request"); - for _ in 0..PROCESSING_FAILURES { - let id = rig.expect_block_parent_request(parent_root); - // Blobs are only requested in the previous first iteration as this test only retries blocks - rig.assert_not_ignored_chain(block_root); - // send the right parent but fail processing - rig.parent_lookup_block_response(id, peer_id, Some(parent.clone().into())); - rig.parent_block_processed(block_root, BlockError::BlockSlotLimitReached.into()); - rig.parent_lookup_block_response(id, peer_id, None); - rig.expect_penalty(peer_id, "lookup_block_processing_failure"); - } - - rig.assert_not_ignored_chain(block_root); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_too_deep_grow_ancestor() { - let mut rig = TestRig::test_setup(); - let mut blocks = rig.rand_blockchain(PARENT_DEPTH_TOLERANCE); - - let peer_id = rig.new_connected_peer(); - let trigger_block = blocks.pop().unwrap(); - let chain_hash = trigger_block.canonical_root(); - rig.trigger_unknown_parent_block(peer_id, trigger_block); - - for block in blocks.into_iter().rev() { - let id = rig.expect_block_parent_request(block.canonical_root()); - // the block - rig.parent_lookup_block_response(id, peer_id, Some(block.clone())); - // the stream termination - rig.parent_lookup_block_response(id, peer_id, None); - // the processing request - rig.expect_block_process(ResponseType::Block); - // the processing result - rig.parent_block_processed( - chain_hash, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ) - } - - // Should create a new syncing chain - rig.drain_sync_rx(); - assert_eq!( - rig.active_range_sync_chain(), - ( - RangeSyncType::Head, - Slot::new(0), - Slot::new(PARENT_DEPTH_TOLERANCE as u64 - 1) - ) - ); + r.assert_head_slot(PARENT_DEPTH_TOLERANCE as u64 + 1); + r.assert_no_penalties(); // Should not penalize peer, but network is not clear because of the blocks_by_range requests - rig.expect_no_penalty_for(peer_id); - rig.assert_ignored_chain(chain_hash); + // r.assert_ignored_chain(chain_hash); + // + // Assert that chain is in failed chains + // Assert that there were 0 lookups completed, 33 dropped + // Assert that there were 1 range sync chains + // Bound resources: + // - Limit amount of requests + // - Limit the types of sync used + assert_eq!(r.completed_lookups(), 0, "no completed lookups"); + assert_eq!( + r.dropped_lookups(), + PARENT_DEPTH_TOLERANCE, + "All lookups dropped" + ); + r.assert_successful_range_sync(); +} + +#[tokio::test] +async fn test_parent_lookup_too_deep_grow_ancestor_zero() { + let mut r = TestRig::default(); + r.build_chain(PARENT_DEPTH_TOLERANCE).await; + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + + r.assert_head_slot(PARENT_DEPTH_TOLERANCE as u64); + r.assert_no_penalties(); + assert_eq!( + r.completed_lookups(), + PARENT_DEPTH_TOLERANCE, + "completed all lookups" + ); + assert_eq!(r.dropped_lookups(), 0, "no dropped lookups"); } // Regression test for https://github.com/sigp/lighthouse/pull/7118 // 8042 UPDATE: block was previously added to the failed_chains cache, now it's inserted into the -// ignored chains cache. The regression test still applies as the chaild lookup is not created -#[test] -fn test_child_lookup_not_created_for_ignored_chain_parent_after_processing() { - // GIVEN: A parent chain longer than PARENT_DEPTH_TOLERANCE. - let mut rig = TestRig::test_setup(); - let mut blocks = rig.rand_blockchain(PARENT_DEPTH_TOLERANCE + 1); - let peer_id = rig.new_connected_peer(); - - // The child of the trigger block to be used to extend the chain. - let trigger_block_child = blocks.pop().unwrap(); - // The trigger block that starts the lookup. - let trigger_block = blocks.pop().unwrap(); - let tip_root = trigger_block.canonical_root(); - - // Trigger the initial unknown parent block for the tip. - rig.trigger_unknown_parent_block(peer_id, trigger_block.clone()); - - // Simulate the lookup chain building up via `ParentUnknown` errors. - for block in blocks.into_iter().rev() { - let id = rig.expect_block_parent_request(block.canonical_root()); - rig.parent_lookup_block_response(id, peer_id, Some(block.clone())); - rig.parent_lookup_block_response(id, peer_id, None); - rig.expect_block_process(ResponseType::Block); - rig.parent_block_processed( - tip_root, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ); - } +// ignored chains cache. The regression test still applies as the child lookup is not created +#[tokio::test] +async fn test_child_lookup_not_created_for_ignored_chain_parent_after_processing() { + let mut r = TestRig::default(); + let depth = PARENT_DEPTH_TOLERANCE + 1; + r.build_chain(depth + 1).await; + r.trigger_with_block_at_slot(depth as u64); + r.simulate(SimulateConfig::new().no_range_sync()).await; // At this point, the chain should have been deemed too deep and pruned. // The tip root should have been inserted into ignored chains. - rig.assert_ignored_chain(tip_root); - rig.expect_no_penalty_for(peer_id); + // Ensure no blocks have been synced + r.assert_head_slot(0); + r.assert_no_active_lookups(); + r.assert_no_penalties(); + r.assert_ignored_chain(r.block_at_slot(depth as u64).canonical_root()); // WHEN: Trigger the extending block that points to the tip. - let trigger_block_child_root = trigger_block_child.canonical_root(); - rig.trigger_unknown_block_from_attestation(trigger_block_child_root, peer_id); - let id = rig.expect_block_lookup_request(trigger_block_child_root); - rig.single_lookup_block_response(id, peer_id, Some(trigger_block_child.clone())); - rig.single_lookup_block_response(id, peer_id, None); - rig.expect_block_process(ResponseType::Block); - rig.single_block_component_processed( - id.lookup_id, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: tip_root, - }), - ); - + let peer = r.new_connected_peer(); + r.trigger_unknown_parent_block(peer, r.block_at_slot(depth as u64 + 1)); // THEN: The extending block should not create a lookup because the tip was inserted into // ignored chains. - rig.expect_no_active_lookups(); - rig.expect_no_penalty_for(peer_id); - rig.expect_empty_network(); + r.assert_no_active_lookups(); + r.assert_no_penalties(); + r.assert_empty_network(); } -#[test] -fn test_parent_lookup_too_deep_grow_tip() { - let mut rig = TestRig::test_setup(); - let blocks = rig.rand_blockchain(PARENT_DEPTH_TOLERANCE - 1); - let peer_id = rig.new_connected_peer(); - let tip = blocks.last().unwrap().clone(); - - for block in blocks.into_iter() { - let block_root = block.canonical_root(); - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - let id = rig.expect_block_parent_request(block_root); - rig.single_lookup_block_response(id, peer_id, Some(block.clone())); - rig.single_lookup_block_response(id, peer_id, None); - rig.expect_block_process(ResponseType::Block); - rig.single_block_component_processed( - id.lookup_id, - BlockError::ParentUnknown { - parent_root: block.parent_root(), - } - .into(), - ); +#[tokio::test] +/// Assert that if a lookup chain (by appending tips) is too long we drop it +async fn test_parent_lookup_too_deep_grow_tip() { + let depth = PARENT_DEPTH_TOLERANCE + 1; + let mut r = TestRig::default(); + r.build_chain(depth).await; + for slot in (1..=depth).rev() { + r.trigger_with_block_at_slot(slot as u64); } + r.simulate(SimulateConfig::happy_path()).await; - // Should create a new syncing chain - rig.drain_sync_rx(); + // Even if the chain is longer than `PARENT_DEPTH_TOLERANCE` because the lookups are created all + // at once they chain by sections and it's possible that the oldest ancestors start processing + // before the full chain is connected. + assert!(r.created_lookups() > 0, "no created lookups"); assert_eq!( - rig.active_range_sync_chain(), - ( - RangeSyncType::Head, - Slot::new(0), - Slot::new(PARENT_DEPTH_TOLERANCE as u64 - 2) - ) + r.completed_lookups(), + r.created_lookups(), + "not all completed lookups" ); + assert_eq!(r.dropped_lookups(), 0, "some dropped lookups"); + r.assert_successful_lookup_sync(); // Should not penalize peer, but network is not clear because of the blocks_by_range requests - rig.expect_no_penalty_for(peer_id); - rig.assert_ignored_chain(tip.canonical_root()); + r.assert_no_penalties(); } -#[test] -fn test_lookup_peer_disconnected_no_peers_left_while_request() { - let mut rig = TestRig::test_setup(); - let peer_id = rig.new_connected_peer(); - let trigger_block = rig.rand_block(); - rig.trigger_unknown_parent_block(peer_id, trigger_block.into()); - rig.peer_disconnected(peer_id); - rig.rpc_error_all_active_requests(peer_id); - // Erroring all rpc requests and disconnecting the peer shouldn't remove the requests - // from the lookups map as they can still progress. - rig.assert_single_lookups_count(2); -} - -#[test] -fn test_lookup_disconnection_peer_left() { - let mut rig = TestRig::test_setup(); - let peer_ids = (0..2).map(|_| rig.new_connected_peer()).collect::<Vec<_>>(); - let disconnecting_peer = *peer_ids.first().unwrap(); - let block_root = Hash256::random(); - // lookup should have two peers associated with the same block - for peer_id in peer_ids.iter() { - rig.trigger_unknown_block_from_attestation(block_root, *peer_id); - } - // Disconnect the first peer only, which is the one handling the request - rig.peer_disconnected(disconnecting_peer); - rig.rpc_error_all_active_requests(disconnecting_peer); - rig.assert_single_lookups_count(1); -} - -#[test] -fn test_lookup_add_peers_to_parent() { - let mut r = TestRig::test_setup(); - let peer_id_1 = r.new_connected_peer(); - let peer_id_2 = r.new_connected_peer(); - let blocks = r.rand_blockchain(5); - let last_block_root = blocks.last().unwrap().canonical_root(); - // Create a chain of lookups - for block in &blocks { - r.trigger_unknown_parent_block(peer_id_1, block.clone()); - } - r.trigger_unknown_block_from_attestation(last_block_root, peer_id_2); - for block in blocks.iter().take(blocks.len() - 1) { - // Parent has the original unknown parent event peer + new peer - r.assert_lookup_peers(block.canonical_root(), vec![peer_id_1, peer_id_2]); - } - // Child lookup only has the unknown attestation peer - r.assert_lookup_peers(last_block_root, vec![peer_id_2]); -} - -#[test] -fn test_skip_creating_ignored_parent_lookup() { - let mut rig = TestRig::test_setup(); - let (_, block, parent_root, _) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - rig.insert_ignored_chain(parent_root); - rig.trigger_unknown_parent_block(peer_id, block.into()); - rig.expect_no_penalty_for(peer_id); +#[tokio::test] +async fn test_skip_creating_ignored_parent_lookup() { + let mut r = TestRig::default(); + r.build_chain(2).await; + r.insert_ignored_chain(r.block_root_at_slot(1)); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_no_penalties(); // Both current and parent lookup should not be created - rig.expect_no_active_lookups(); + r.assert_no_active_lookups(); } -#[test] -fn test_single_block_lookup_ignored_response() { - let mut rig = TestRig::test_setup(); +#[tokio::test] +/// Assert that if the oldest block in a chain is already imported (DuplicateFullyImported), +/// the remaining blocks in the chain are still processed successfully. This tests a race +/// condition where a block gets imported elsewhere while the lookup is processing. +/// +/// The processing sequence is: +/// - Block 3: UnknownParent (needs block 2) +/// - Block 2: UnknownParent (needs block 1) +/// - Block 1: About to be processed, but gets imported via gossip (race condition) +/// - Block 1: DuplicateFullyImported (already in chain from race) +/// - Block 2: Import ok (parent block 1 is available) +/// - Block 3: Import ok (parent block 2 is available) +async fn test_same_chain_race_condition() { + let mut r = TestRig::default(); + r.build_chain(3).await; - let block = rig.rand_block(); - let peer_id = rig.new_connected_peer(); + let block_1_root = r.block_root_at_slot(1); - // Trigger the request - rig.trigger_unknown_block_from_attestation(block.canonical_root(), peer_id); - let id = rig.expect_block_lookup_request(block.canonical_root()); + // Trigger a lookup with block 3. This creates a parent lookup chain that will + // request blocks 3 → 2 → 1. + r.trigger_with_block_at_slot(3); - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - rig.single_lookup_block_response(id, peer_id, Some(block.into())); - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); + // Configure simulate to import block 1 right before it's processed by the lookup. + // This simulates the race condition where block 1 arrives via gossip at the same + // time the lookup is trying to process it. + r.simulate(SimulateConfig::new().with_import_block_before_process(block_1_root)) + .await; - // The request should still be active. - assert_eq!(rig.active_single_lookups_count(), 1); - - // Send the stream termination. Peer should have not been penalized, and the request removed - // after processing. - rig.single_lookup_block_response(id, peer_id, None); - // Send an Ignored response, the request should be dropped - rig.single_block_component_processed(id.lookup_id, BlockProcessingResult::Ignored); - rig.expect_no_active_lookups_empty_network(); + // The chain should complete successfully with head at slot 3, proving that + // the lookup correctly handled the DuplicateFullyImported for block 1 and + // continued processing blocks 2 and 3. + r.assert_head_slot(3); + r.assert_successful_lookup_sync(); } -#[test] -fn test_parent_lookup_ignored_response() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.clone().into()); - let id = rig.expect_block_parent_request(parent_root); - // Note: single block lookup for current `block` does not trigger any request because it does - // not have blobs, and the block is already cached - - // Peer sends the right block, it should be sent for processing. Peer should not be penalized. - rig.parent_lookup_block_response(id, peer_id, Some(parent.into())); - rig.expect_block_process(ResponseType::Block); - rig.expect_empty_network(); - - // Return an Ignored result. The request should be dropped - rig.parent_block_processed(block_root, BlockProcessingResult::Ignored); - rig.expect_empty_network(); - rig.expect_no_active_lookups(); -} - -/// This is a regression test. -#[test] -fn test_same_chain_race_condition() { - let mut rig = TestRig::test_setup(); - - // if we use one or two blocks it will match on the hash or the parent hash, so make a longer - // chain. - let depth = 4; - let mut blocks = rig.rand_blockchain(depth); - let peer_id = rig.new_connected_peer(); - let trigger_block = blocks.pop().unwrap(); - let chain_hash = trigger_block.canonical_root(); - rig.trigger_unknown_parent_block(peer_id, trigger_block.clone()); - - for (i, block) in blocks.clone().into_iter().rev().enumerate() { - let id = rig.expect_block_parent_request(block.canonical_root()); - // the block - rig.parent_lookup_block_response(id, peer_id, Some(block.clone())); - // the stream termination - rig.parent_lookup_block_response(id, peer_id, None); - // the processing request - rig.expect_block_process(ResponseType::Block); - // the processing result - if i + 2 == depth { - rig.log(&format!("Block {i} was removed and is already known")); - rig.parent_block_processed( - chain_hash, - BlockError::DuplicateFullyImported(block.canonical_root()).into(), - ) - } else { - rig.log(&format!("Block {i} ParentUnknown")); - rig.parent_block_processed( - chain_hash, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ) - } - } - - // Try to get this block again while the chain is being processed. We should not request it again. - let peer_id = rig.new_connected_peer(); - rig.trigger_unknown_parent_block(peer_id, trigger_block.clone()); - rig.expect_empty_network(); - - // Add a peer to the tip child lookup which has zero peers - rig.trigger_unknown_block_from_attestation(trigger_block.canonical_root(), peer_id); - - rig.log("Processing succeeds, now the rest of the chain should be sent for processing."); - for block in blocks.iter().skip(1).chain(&[trigger_block]) { - rig.expect_parent_chain_process(); - rig.single_block_component_processed_imported(block.canonical_root()); - } - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn block_in_da_checker_skips_download() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +/// Assert that if the lookup's block is in the da_checker we don't download it again +async fn block_in_da_checker_skips_download() { + // Only in Deneb, as the block needs blobs to remain in the da_checker + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.insert_block_to_da_checker(block.into()); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should not trigger block request - let id = r.expect_blob_lookup_request(block_root); - r.expect_empty_network(); - // Resolve blob and expect lookup completed - r.complete_single_lookup_blob_lookup_valid(id, peer_id, blobs, true); - r.expect_no_active_lookups(); + // Add block to da_checker + // Complete test with happy path + // Assert that there were no requests for blocks + r.build_chain(1).await; + r.insert_block_to_da_chain_and_assert_missing_componens(r.block_at_slot(1)) + .await; + r.trigger_with_block_at_slot(1); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); + assert_eq!( + r.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::BlocksByRoot(_))) + .collect::<Vec<_>>(), + Vec::<&(RequestType<E>, AppRequestId)>::new(), + "There should be no block requests" + ); } -#[test] -fn block_in_processing_cache_becomes_invalid() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +async fn block_in_processing_cache_becomes_invalid() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.insert_block_to_availability_cache(block.clone().into()); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should trigger blob request - let id = r.expect_blob_lookup_request(block_root); - // Should not trigger block request - r.expect_empty_network(); + r.build_chain(1).await; + let block = r.block_at_slot(1); + r.insert_block_to_da_checker_as_pre_execution(block.clone()); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_pending_lookup_sync(); + // Here the only active lookup is waiting for the block to finish processing + // Simulate invalid block, removing it from processing cache - r.simulate_block_gossip_processing_becomes_invalid(block_root); + r.simulate_block_gossip_processing_becomes_invalid(block.canonical_root()); // Should download block, then issue blobs request - r.complete_lookup_block_download(block); - // Should not trigger block or blob request - r.expect_empty_network(); - r.complete_lookup_block_import_valid(block_root, false); - // Resolve blob and expect lookup completed - r.complete_single_lookup_blob_lookup_valid(id, peer_id, blobs, true); - r.expect_no_active_lookups(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); } -#[test] -fn block_in_processing_cache_becomes_valid_imported() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +async fn block_in_processing_cache_becomes_valid_imported() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.insert_block_to_availability_cache(block.clone().into()); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should trigger blob request - let id = r.expect_blob_lookup_request(block_root); - // Should not trigger block request - r.expect_empty_network(); + r.build_chain(1).await; + let block = r.block_at_slot(1); + r.insert_block_to_da_checker_as_pre_execution(block.clone()); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_pending_lookup_sync(); + // Here the only active lookup is waiting for the block to finish processing + // Resolve the block from processing step - r.simulate_block_gossip_processing_becomes_valid_missing_components(block.into()); + r.simulate_block_gossip_processing_becomes_valid(block) + .await; // Should not trigger block or blob request - r.expect_empty_network(); + r.assert_empty_network(); // Resolve blob and expect lookup completed - r.complete_single_lookup_blob_lookup_valid(id, peer_id, blobs, true); - r.expect_no_active_lookups(); + r.assert_no_active_lookups(); } // IGNORE: wait for change that delays blob fetching to knowing the block -#[ignore] -#[test] -fn blobs_in_da_checker_skip_download() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +async fn blobs_in_da_checker_skip_download() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - for blob in blobs { - r.insert_blob_to_da_checker(blob); + r.build_chain(1).await; + let block = r.get_last_block().clone(); + let blobs = block.block_data().blobs().expect("block with no blobs"); + for blob in &blobs { + r.insert_blob_to_da_checker(blob.clone()); } - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should download and process the block - r.complete_single_lookup_block_valid(block, true); - // Should not trigger blob request - r.expect_empty_network(); - r.expect_no_active_lookups(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + + r.assert_successful_lookup_sync(); + assert_eq!( + r.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::BlobsByRoot(_))) + .collect::<Vec<_>>(), + Vec::<&(RequestType<E>, AppRequestId)>::new(), + "There should be no blob requests" + ); } -#[test] -fn custody_lookup_happy_path() { - let Some(mut r) = TestRig::test_setup_after_fulu() else { +macro_rules! fulu_peer_matrix_tests { + ( + [$($name:ident => $variant:expr),+ $(,)?] + ) => { + paste::paste! { + $( + #[tokio::test] + async fn [<custody_lookup_happy_path _ $name>]() { + custody_lookup_happy_path($variant).await; + } + + #[tokio::test] + async fn [<custody_lookup_some_custody_failures _ $name>]() { + custody_lookup_some_custody_failures($variant).await; + } + + #[tokio::test] + async fn [<custody_lookup_permanent_custody_failures _ $name>]() { + custody_lookup_permanent_custody_failures($variant).await; + } + )+ + } + }; +} + +fulu_peer_matrix_tests!( + [ + we_supernode_them_supernode => FuluTestType::WeSupernodeThemSupernode, + we_supernode_them_fullnodes => FuluTestType::WeSupernodeThemFullnodes, + we_fullnode_them_supernode => FuluTestType::WeFullnodeThemSupernode, + we_fullnode_them_fullnodes => FuluTestType::WeFullnodeThemFullnodes, + ] +); + +async fn custody_lookup_happy_path(test_type: FuluTestType) { + let Some(mut r) = TestRig::new_fulu_peer_test(test_type) else { return; }; - let spec = E::default_spec(); + r.build_chain(1).await; r.new_connected_peers_for_peerdas(); - let (block, data_columns) = r.rand_block_and_data_columns(); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should not request blobs - let id = r.expect_block_lookup_request(block.canonical_root()); - r.complete_valid_block_request(id, block.into(), true); - // for each slot we download `samples_per_slot` columns - let sample_column_count = spec.samples_per_slot * spec.data_columns_per_group::<E>(); - let custody_ids = - r.expect_only_data_columns_by_root_requests(block_root, sample_column_count as usize); - r.complete_valid_custody_request(custody_ids, data_columns, false); - r.expect_no_active_lookups(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_no_penalties(); + r.assert_successful_lookup_sync(); } +async fn custody_lookup_some_custody_failures(test_type: FuluTestType) { + let Some(mut r) = TestRig::new_fulu_peer_test(test_type) else { + return; + }; + let block_root = r.build_chain(1).await; + // Send the same trigger from all peers, so that the lookup has all peers + for peer in r.new_connected_peers_for_peerdas() { + r.trigger_unknown_block_from_attestation(block_root, peer); + } + let custody_columns = r.custody_columns(); + r.simulate(SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..4], 3)) + .await; + r.assert_penalties_of_type("NotEnoughResponsesReturned"); + r.assert_successful_lookup_sync(); +} + +async fn custody_lookup_permanent_custody_failures(test_type: FuluTestType) { + let Some(mut r) = TestRig::new_fulu_peer_test(test_type) else { + return; + }; + let block_root = r.build_chain(1).await; + + // Send the same trigger from all peers, so that the lookup has all peers + for peer in r.new_connected_peers_for_peerdas() { + r.trigger_unknown_block_from_attestation(block_root, peer); + } + + let custody_columns = r.custody_columns(); + r.simulate( + SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..2], usize::MAX), + ) + .await; + // Every peer that does not return a column is part of the lookup because it claimed to have + // imported the lookup, so we will penalize. + r.assert_penalties_of_type("NotEnoughResponsesReturned"); + r.assert_failed_lookup_sync(); +} + +// We supernode, diverse peers +// We not supernode, diverse peers + // TODO(das): Test retries of DataColumnByRoot: // - Expect request for column_index // - Respond with bad data // - Respond with stream terminator // ^ The stream terminator should be ignored and not close the next retry -mod deneb_only { - use super::*; - use beacon_chain::{ - block_verification_types::{AsBlock, RpcBlock}, - data_availability_checker::AvailabilityCheckError, - }; - use ssz_types::RuntimeVariableList; - use std::collections::VecDeque; - - struct DenebTester { - rig: TestRig, - block: Arc<SignedBeaconBlock<E>>, - blobs: Vec<Arc<BlobSidecar<E>>>, - parent_block_roots: Vec<Hash256>, - parent_block: VecDeque<Arc<SignedBeaconBlock<E>>>, - parent_blobs: VecDeque<Vec<Arc<BlobSidecar<E>>>>, - unknown_parent_block: Option<Arc<SignedBeaconBlock<E>>>, - unknown_parent_blobs: Option<Vec<Arc<BlobSidecar<E>>>>, - peer_id: PeerId, - block_req_id: Option<SingleLookupReqId>, - parent_block_req_id: Option<SingleLookupReqId>, - blob_req_id: Option<SingleLookupReqId>, - parent_blob_req_id: Option<SingleLookupReqId>, - slot: Slot, - block_root: Hash256, - } - - enum RequestTrigger { - AttestationUnknownBlock, - GossipUnknownParentBlock(usize), - GossipUnknownParentBlob(usize), - } - - impl RequestTrigger { - fn num_parents(&self) -> usize { - match self { - RequestTrigger::AttestationUnknownBlock => 0, - RequestTrigger::GossipUnknownParentBlock(num_parents) => *num_parents, - RequestTrigger::GossipUnknownParentBlob(num_parents) => *num_parents, - } - } - } - - impl DenebTester { - fn new(request_trigger: RequestTrigger) -> Option<Self> { - let Some(mut rig) = TestRig::test_setup_after_deneb_before_fulu() else { - return None; - }; - let (block, blobs) = rig.rand_block_and_blobs(NumBlobs::Random); - let mut block = Arc::new(block); - let mut blobs = blobs.into_iter().map(Arc::new).collect::<Vec<_>>(); - let slot = block.slot(); - - let num_parents = request_trigger.num_parents(); - let mut parent_block_chain = VecDeque::with_capacity(num_parents); - let mut parent_blobs_chain = VecDeque::with_capacity(num_parents); - let mut parent_block_roots = vec![]; - for _ in 0..num_parents { - // Set the current block as the parent. - let parent_root = block.canonical_root(); - let parent_block = block.clone(); - let parent_blobs = blobs.clone(); - parent_block_chain.push_front(parent_block); - parent_blobs_chain.push_front(parent_blobs); - parent_block_roots.push(parent_root); - - // Create the next block. - let (child_block, child_blobs) = - rig.block_with_parent_and_blobs(parent_root, NumBlobs::Random); - let mut child_block = Arc::new(child_block); - let mut child_blobs = child_blobs.into_iter().map(Arc::new).collect::<Vec<_>>(); - - // Update the new block to the current block. - std::mem::swap(&mut child_block, &mut block); - std::mem::swap(&mut child_blobs, &mut blobs); - } - let block_root = block.canonical_root(); - - let peer_id = rig.new_connected_peer(); - - // Trigger the request - let (block_req_id, blob_req_id, parent_block_req_id, parent_blob_req_id) = - match request_trigger { - RequestTrigger::AttestationUnknownBlock => { - rig.send_sync_message(SyncMessage::UnknownBlockHashFromAttestation( - peer_id, block_root, - )); - let block_req_id = rig.expect_block_lookup_request(block_root); - (Some(block_req_id), None, None, None) - } - RequestTrigger::GossipUnknownParentBlock { .. } => { - rig.send_sync_message(SyncMessage::UnknownParentBlock( - peer_id, - block.clone(), - block_root, - )); - - let parent_root = block.parent_root(); - let parent_block_req_id = rig.expect_block_parent_request(parent_root); - rig.expect_empty_network(); // expect no more requests - (None, None, Some(parent_block_req_id), None) - } - RequestTrigger::GossipUnknownParentBlob { .. } => { - let single_blob = blobs.first().cloned().unwrap(); - let parent_root = single_blob.block_parent_root(); - rig.send_sync_message(SyncMessage::UnknownParentBlob(peer_id, single_blob)); - - let parent_block_req_id = rig.expect_block_parent_request(parent_root); - rig.expect_empty_network(); // expect no more requests - (None, None, Some(parent_block_req_id), None) - } - }; - - Some(Self { - rig, - block, - blobs, - parent_block: parent_block_chain, - parent_blobs: parent_blobs_chain, - parent_block_roots, - unknown_parent_block: None, - unknown_parent_blobs: None, - peer_id, - block_req_id, - parent_block_req_id, - blob_req_id, - parent_blob_req_id, - slot, - block_root, - }) - } - - fn trigger_unknown_block_from_attestation(mut self) -> Self { - let block_root = self.block.canonical_root(); - self.rig - .trigger_unknown_block_from_attestation(block_root, self.peer_id); - self - } - - fn parent_block_response(mut self) -> Self { - self.rig.expect_empty_network(); - let block = self.parent_block.pop_front().unwrap().clone(); - let _ = self.unknown_parent_block.insert(block.clone()); - self.rig.parent_lookup_block_response( - self.parent_block_req_id.expect("parent request id"), - self.peer_id, - Some(block), - ); - - self.rig.assert_parent_lookups_count(1); - self - } - - fn parent_block_response_expect_blobs(mut self) -> Self { - self.rig.expect_empty_network(); - let block = self.parent_block.pop_front().unwrap().clone(); - let _ = self.unknown_parent_block.insert(block.clone()); - self.rig.parent_lookup_block_response( - self.parent_block_req_id.expect("parent request id"), - self.peer_id, - Some(block), - ); - - // Expect blobs request after sending block - let s = self.expect_parent_blobs_request(); - - s.rig.assert_parent_lookups_count(1); - s - } - - fn parent_blob_response(mut self) -> Self { - let blobs = self.parent_blobs.pop_front().unwrap(); - let _ = self.unknown_parent_blobs.insert(blobs.clone()); - for blob in &blobs { - self.rig.parent_lookup_blob_response( - self.parent_blob_req_id.expect("parent blob request id"), - self.peer_id, - Some(blob.clone()), - ); - assert_eq!(self.rig.active_parent_lookups_count(), 1); - } - self.rig.parent_lookup_blob_response( - self.parent_blob_req_id.expect("parent blob request id"), - self.peer_id, - None, - ); - - self - } - - fn block_response_triggering_process(self) -> Self { - let mut me = self.block_response_and_expect_blob_request(); - me.rig.expect_block_process(ResponseType::Block); - - // The request should still be active. - assert_eq!(me.rig.active_single_lookups_count(), 1); - me - } - - fn block_response_and_expect_blob_request(mut self) -> Self { - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - self.rig.single_lookup_block_response( - self.block_req_id.expect("block request id"), - self.peer_id, - Some(self.block.clone()), - ); - // After responding with block the node will issue a blob request - let mut s = self.expect_blobs_request(); - - s.rig.expect_empty_network(); - - // The request should still be active. - s.rig.assert_lookup_is_active(s.block.canonical_root()); - s - } - - fn blobs_response(mut self) -> Self { - self.rig - .log(&format!("blobs response {}", self.blobs.len())); - for blob in &self.blobs { - self.rig.single_lookup_blob_response( - self.blob_req_id.expect("blob request id"), - self.peer_id, - Some(blob.clone()), - ); - self.rig - .assert_lookup_is_active(self.block.canonical_root()); - } - self.rig.single_lookup_blob_response( - self.blob_req_id.expect("blob request id"), - self.peer_id, - None, - ); - self - } - - fn blobs_response_was_valid(mut self) -> Self { - self.rig.expect_empty_network(); - if !self.blobs.is_empty() { - self.rig.expect_block_process(ResponseType::Blob); - } - self - } - - fn expect_empty_beacon_processor(mut self) -> Self { - self.rig.expect_empty_beacon_processor(); - self - } - - fn empty_block_response(mut self) -> Self { - self.rig.single_lookup_block_response( - self.block_req_id.expect("block request id"), - self.peer_id, - None, - ); - self - } - - fn empty_blobs_response(mut self) -> Self { - self.rig.single_lookup_blob_response( - self.blob_req_id.expect("blob request id"), - self.peer_id, - None, - ); - self - } - - fn empty_parent_blobs_response(mut self) -> Self { - self.rig.parent_lookup_blob_response( - self.parent_blob_req_id.expect("blob request id"), - self.peer_id, - None, - ); - self - } - - fn block_missing_components(mut self) -> Self { - self.rig.single_block_component_processed( - self.block_req_id.expect("block request id").lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - self.block.slot(), - self.block_root, - )), - ); - self.rig.expect_empty_network(); - self.rig.assert_single_lookups_count(1); - self - } - - fn blob_imported(mut self) -> Self { - self.rig.single_blob_component_processed( - self.blob_req_id.expect("blob request id").lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(self.block_root)), - ); - self.rig.expect_empty_network(); - self.rig.assert_single_lookups_count(0); - self - } - - fn block_imported(mut self) -> Self { - // Missing blobs should be the request is not removed, the outstanding blobs request should - // mean we do not send a new request. - self.rig.single_block_component_processed( - self.block_req_id - .or(self.blob_req_id) - .expect("block request id") - .lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(self.block_root)), - ); - self.rig.expect_empty_network(); - self.rig.assert_single_lookups_count(0); - self - } - - fn parent_block_imported(mut self) -> Self { - let parent_root = *self.parent_block_roots.first().unwrap(); - self.rig - .log(&format!("parent_block_imported {parent_root:?}")); - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(parent_root)), - ); - self.rig.expect_no_requests_for(parent_root); - self.rig.assert_parent_lookups_count(0); - self - } - - fn parent_block_missing_components(mut self) -> Self { - let parent_root = *self.parent_block_roots.first().unwrap(); - self.rig - .log(&format!("parent_block_missing_components {parent_root:?}")); - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - Slot::new(0), - parent_root, - )), - ); - self.rig.expect_no_requests_for(parent_root); - self - } - - fn parent_blob_imported(mut self) -> Self { - let parent_root = *self.parent_block_roots.first().unwrap(); - self.rig - .log(&format!("parent_blob_imported {parent_root:?}")); - self.rig.parent_blob_processed( - self.block_root, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(parent_root)), - ); - - self.rig.expect_no_requests_for(parent_root); - self.rig.assert_parent_lookups_count(0); - self - } - - fn parent_block_unknown_parent(mut self) -> Self { - self.rig.log("parent_block_unknown_parent"); - let block = self.unknown_parent_block.take().unwrap(); - let max_len = self.rig.spec.max_blobs_per_block(block.epoch()) as usize; - // Now this block is the one we expect requests from - self.block = block.clone(); - let block = RpcBlock::new( - Some(block.canonical_root()), - block, - self.unknown_parent_blobs - .take() - .map(|vec| RuntimeVariableList::new(vec, max_len).unwrap()), - ) - .unwrap(); - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ); - assert_eq!(self.rig.active_parent_lookups_count(), 1); - self - } - - fn invalid_parent_processed(mut self) -> Self { - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Err(BlockError::BlockSlotLimitReached), - ); - assert_eq!(self.rig.active_parent_lookups_count(), 1); - self - } - - fn invalid_block_processed(mut self) -> Self { - self.rig.single_block_component_processed( - self.block_req_id.expect("block request id").lookup_id, - BlockProcessingResult::Err(BlockError::BlockSlotLimitReached), - ); - self.rig.assert_single_lookups_count(1); - self - } - - fn invalid_blob_processed(mut self) -> Self { - self.rig.log("invalid_blob_processed"); - self.rig.single_blob_component_processed( - self.blob_req_id.expect("blob request id").lookup_id, - BlockProcessingResult::Err(BlockError::AvailabilityCheck( - AvailabilityCheckError::InvalidBlobs(kzg::Error::KzgVerificationFailed), - )), - ); - self.rig.assert_single_lookups_count(1); - self - } - - fn missing_components_from_block_request(mut self) -> Self { - self.rig.single_block_component_processed( - self.block_req_id.expect("block request id").lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - self.slot, - self.block_root, - )), - ); - // Add block to da_checker so blobs request can continue - self.rig.insert_block_to_da_checker(self.block.clone()); - - self.rig.assert_single_lookups_count(1); - self - } - - fn complete_current_block_and_blobs_lookup(self) -> Self { - self.expect_block_request() - .block_response_and_expect_blob_request() - .blobs_response() - // TODO: Should send blobs for processing - .expect_block_process() - .block_imported() - } - - fn log(self, msg: &str) -> Self { - self.rig.log(msg); - self - } - - fn parent_block_then_empty_parent_blobs(self) -> Self { - self.log( - " Return empty blobs for parent, block errors with missing components, downscore", - ) - .parent_block_response() - .expect_parent_blobs_request() - .empty_parent_blobs_response() - .expect_penalty("NotEnoughResponsesReturned") - .log("Re-request parent blobs, succeed and import parent") - .expect_parent_blobs_request() - .parent_blob_response() - .expect_block_process() - .parent_block_missing_components() - // Insert new peer into child request before completing parent - .trigger_unknown_block_from_attestation() - .parent_blob_imported() - } - - fn expect_penalty(mut self, expect_penalty_msg: &'static str) -> Self { - self.rig.expect_penalty(self.peer_id, expect_penalty_msg); - self - } - fn expect_no_penalty(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn expect_no_penalty_and_no_requests(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn expect_block_request(mut self) -> Self { - let id = self - .rig - .expect_block_lookup_request(self.block.canonical_root()); - self.block_req_id = Some(id); - self - } - fn expect_blobs_request(mut self) -> Self { - let id = self - .rig - .expect_blob_lookup_request(self.block.canonical_root()); - self.blob_req_id = Some(id); - self - } - fn expect_parent_block_request(mut self) -> Self { - let id = self - .rig - .expect_block_parent_request(self.block.parent_root()); - self.parent_block_req_id = Some(id); - self - } - fn expect_parent_blobs_request(mut self) -> Self { - let id = self - .rig - .expect_blob_parent_request(self.block.parent_root()); - self.parent_blob_req_id = Some(id); - self - } - fn expect_no_blobs_request(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn expect_no_block_request(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn invalidate_blobs_too_few(mut self) -> Self { - self.blobs.pop().expect("blobs"); - self - } - fn expect_block_process(mut self) -> Self { - self.rig.expect_block_process(ResponseType::Block); - self - } - fn expect_no_active_lookups(self) -> Self { - self.rig.expect_no_active_lookups(); - self - } - fn search_parent_dup(mut self) -> Self { - self.rig - .trigger_unknown_parent_block(self.peer_id, self.block.clone()); - self - } - } - - #[test] - fn single_block_and_blob_lookup_block_returned_first_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_and_expect_blob_request() - .blobs_response() - .block_missing_components() // blobs not yet imported - .blobs_response_was_valid() - .blob_imported(); // now blobs resolve as imported - } - - #[test] - fn single_block_response_then_empty_blob_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_and_expect_blob_request() - .missing_components_from_block_request() - .empty_blobs_response() - .expect_penalty("NotEnoughResponsesReturned") - .expect_blobs_request() - .expect_no_block_request(); - } - - #[test] - fn single_invalid_block_response_then_blob_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_triggering_process() - .invalid_block_processed() - .expect_penalty("lookup_block_processing_failure") - .expect_block_request() - .expect_no_blobs_request() - .blobs_response() - // blobs not sent for processing until the block is processed - .expect_no_penalty_and_no_requests(); - } - - #[test] - fn single_block_response_then_invalid_blob_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_triggering_process() - .missing_components_from_block_request() - .blobs_response() - .invalid_blob_processed() - .expect_penalty("lookup_blobs_processing_failure") - .expect_blobs_request() - .expect_no_block_request(); - } - - #[test] - fn single_block_response_then_too_few_blobs_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_triggering_process() - .missing_components_from_block_request() - .invalidate_blobs_too_few() - .blobs_response() - .expect_penalty("NotEnoughResponsesReturned") - .expect_blobs_request() - .expect_no_block_request(); - } - - // Test peer returning block that has unknown parent, and a new lookup is created - #[test] - fn parent_block_unknown_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .parent_block_unknown_parent() - .expect_parent_block_request() - .expect_empty_beacon_processor(); - } - - // Test peer returning invalid (processing) block, expect retry - #[test] - fn parent_block_invalid_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .invalid_parent_processed() - .expect_penalty("lookup_block_processing_failure") - .expect_parent_block_request() - .expect_empty_beacon_processor(); - } - - // Tests that if a peer does not respond with a block, we downscore and retry the block only - #[test] - fn empty_block_is_retried() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .empty_block_response() - .expect_penalty("NotEnoughResponsesReturned") - .expect_block_request() - .expect_no_blobs_request() - .block_response_and_expect_blob_request() - .blobs_response() - .block_imported() - .expect_no_active_lookups(); - } - - #[test] - fn parent_block_then_empty_parent_blobs() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .parent_block_then_empty_parent_blobs() - .log("resolve original block trigger blobs request and import") - // Should not have block request, it is cached - .expect_blobs_request() - // TODO: Should send blobs for processing - .block_imported() - .expect_no_active_lookups(); - } - - #[test] - fn parent_blob_unknown_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .parent_block_unknown_parent() - .expect_parent_block_request() - .expect_empty_beacon_processor(); - } - - #[test] - fn parent_blob_invalid_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .invalid_parent_processed() - .expect_penalty("lookup_block_processing_failure") - .expect_parent_block_request() - // blobs are not sent until block is processed - .expect_empty_beacon_processor(); - } - - #[test] - fn parent_block_and_blob_lookup_parent_returned_first_blob_trigger() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .parent_block_response() - .expect_parent_blobs_request() - .parent_blob_response() - .expect_block_process() - .trigger_unknown_block_from_attestation() - .parent_block_imported() - .complete_current_block_and_blobs_lookup() - .expect_no_active_lookups(); - } - - #[test] - fn parent_block_then_empty_parent_blobs_blob_trigger() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .parent_block_then_empty_parent_blobs() - .log("resolve original block trigger blobs request and import") - .complete_current_block_and_blobs_lookup() - .expect_no_active_lookups(); - } - - #[test] - fn parent_blob_unknown_parent_chain() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(2)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_no_penalty() - .expect_block_process() - .parent_block_unknown_parent() - .expect_parent_block_request() - .expect_empty_beacon_processor() - .parent_block_response() - .expect_parent_blobs_request() - .parent_blob_response() - .expect_no_penalty() - .expect_block_process(); - } - - #[test] - fn unknown_parent_block_dup() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .search_parent_dup() - .expect_no_blobs_request() - .expect_no_block_request(); - } - - #[test] - fn unknown_parent_blob_dup() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .search_parent_dup() - .expect_no_blobs_request() - .expect_no_block_request(); - } - - // This test no longer applies, we don't issue requests for child lookups - // Keep for after updating rules on fetching blocks only first - #[ignore] - #[test] - fn no_peer_penalty_when_rpc_response_already_known_from_gossip() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { - return; - }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(2)); - let block_root = block.canonical_root(); - let blob_0 = blobs[0].clone(); - let blob_1 = blobs[1].clone(); - let peer_a = r.new_connected_peer(); - let peer_b = r.new_connected_peer(); - // Send unknown parent block lookup - r.trigger_unknown_parent_block(peer_a, block.into()); - // Expect network request for blobs - let id = r.expect_blob_lookup_request(block_root); - // Peer responses with blob 0 - r.single_lookup_blob_response(id, peer_a, Some(blob_0.into())); - // Blob 1 is received via gossip unknown parent blob from a different peer - r.trigger_unknown_parent_blob(peer_b, blob_1.clone()); - // Original peer sends blob 1 via RPC - r.single_lookup_blob_response(id, peer_a, Some(blob_1.into())); - // Assert no downscore event for original peer - r.expect_no_penalty_for(peer_a); +// These `crypto_on` tests assert that the fake_crytpo feature works as expected. We run only the +// `crypto_on` tests without the fake_crypto feature and make sure that processing fails, = to +// assert that signatures and kzg proofs are checked +#[tokio::test] +async fn crypto_on_fail_with_invalid_block_signature() { + let mut r = TestRig::default(); + r.build_chain(1).await; + r.corrupt_last_block_signature(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_block_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_blob_proposer_signature() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_blob_proposer_signature(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_blobs_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_blob_kzg_proof() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_blob_kzg_proof(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_blobs_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_column_proposer_signature() { + let Some(mut r) = TestRig::new_fulu_peer_test(FuluTestType::WeSupernodeThemSupernode) else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_column_proposer_signature(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_custody_column_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_column_kzg_proof() { + let Some(mut r) = TestRig::new_fulu_peer_test(FuluTestType::WeSupernodeThemSupernode) else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_column_kzg_proof(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_custody_column_processing_failure"); } } diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 23c14ff63e..6e948e4726 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -1,13 +1,19 @@ use crate::NetworkMessage; use crate::sync::SyncMessage; +use crate::sync::block_lookups::BlockLookupsMetrics; use crate::sync::manager::SyncManager; -use crate::sync::range_sync::RangeSyncType; +use crate::sync::tests::lookups::SimulateConfig; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::builder::Witness; +use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use beacon_processor::WorkEvent; -use lighthouse_network::NetworkGlobals; +use lighthouse_network::rpc::RequestType; +use lighthouse_network::service::api_types::{AppRequestId, Id}; +use lighthouse_network::{NetworkGlobals, PeerId}; use rand_chacha::ChaCha20Rng; use slot_clock::ManualSlotClock; +use std::collections::{HashMap, HashSet}; use std::fs::OpenOptions; use std::io::Write; use std::sync::{Arc, Once}; @@ -16,7 +22,7 @@ use tokio::sync::mpsc; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use types::{ChainSpec, ForkName, MinimalEthSpec as E}; +use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; mod lookups; mod range; @@ -58,6 +64,8 @@ struct TestRig { network_rx_queue: Vec<NetworkMessage<E>>, /// Receiver for `SyncMessage` from the network sync_rx: mpsc::UnboundedReceiver<SyncMessage<E>>, + /// Stores all `SyncMessage`s received from `sync_rx` + sync_rx_queue: Vec<SyncMessage<E>>, /// To send `SyncMessage`. For sending RPC responses or block processing results to sync. sync_manager: SyncManager<T>, /// To manipulate sync state and peer connection status @@ -68,7 +76,65 @@ struct TestRig { rng_08: rand_chacha_03::ChaCha20Rng, rng: ChaCha20Rng, fork_name: ForkName, - spec: Arc<ChainSpec>, + /// Blocks that will be used in the test but may not be known to `harness` yet. + network_blocks_by_root: HashMap<Hash256, RangeSyncBlock<E>>, + network_blocks_by_slot: HashMap<Slot, RangeSyncBlock<E>>, + penalties: Vec<ReportedPenalty>, + /// All seen lookups through the test run + seen_lookups: HashMap<Id, SeenLookup>, + /// Registry of all requests done by the test + requests: Vec<(RequestType<E>, AppRequestId)>, + /// Persistent config on how to complete request + complete_strategy: SimulateConfig, + /// Metrics values to allow a reset + initial_block_lookups_metrics: BlockLookupsMetrics, + /// Fulu test type + fulu_test_type: FuluTestType, +} + +enum FuluTestType { + WeSupernodeThemSupernode, + WeSupernodeThemFullnodes, + WeFullnodeThemSupernode, + WeFullnodeThemFullnodes, +} + +impl FuluTestType { + fn we_node_custody_type(&self) -> NodeCustodyType { + match self { + Self::WeSupernodeThemSupernode | Self::WeSupernodeThemFullnodes => { + NodeCustodyType::Supernode + } + Self::WeFullnodeThemSupernode | Self::WeFullnodeThemFullnodes => { + NodeCustodyType::Fullnode + } + } + } + + fn them_node_custody_type(&self) -> NodeCustodyType { + match self { + Self::WeSupernodeThemSupernode | Self::WeFullnodeThemSupernode => { + NodeCustodyType::Supernode + } + Self::WeSupernodeThemFullnodes | Self::WeFullnodeThemFullnodes => { + NodeCustodyType::Fullnode + } + } + } +} + +#[derive(Debug)] +struct SeenLookup { + /// Lookup's Id + id: Id, + block_root: Hash256, + seen_peers: HashSet<PeerId>, +} + +#[derive(Debug)] +struct ReportedPenalty { + pub peer_id: PeerId, + pub msg: &'static str, } // Environment variable to read if `fork_from_env` feature is enabled. diff --git a/beacon_node/network/src/sync/tests/range.rs b/beacon_node/network/src/sync/tests/range.rs index cb728a90c1..891d9d1e97 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -1,107 +1,47 @@ +//! Range sync tests for `BlocksByRange`, `BlobsByRange`, `DataColumnsByRange`. +//! +//! Tests follow the pattern from `lookups.rs`: +//! ```ignore +//! async fn test_name() { +//! let mut r = TestRig::default(); +//! r.setup_xyz().await; +//! r.simulate(SimulateConfig::happy_path()).await; +//! r.assert_range_sync_completed(); +//! } +//! ``` +//! +//! Rules: +//! - Tests must be succinct and readable (3-10 lines per test body) +//! - All complex logic lives in helpers (setup, SimulateConfig, assert) +//! - Test bodies must not manually grab requests, send SyncMessages, or do anything overly specific +//! - All tests use `simulate()` if they need peers to fulfill requests +//! - Extend `SimulateConfig` for new range-specific behaviors +//! - Extend `simulate()` to support by_range methods + +use super::lookups::SimulateConfig; use super::*; -use crate::network_beacon_processor::ChainSegmentProcessId; use crate::status::ToStatusMessage; use crate::sync::SyncMessage; use crate::sync::manager::SLOT_IMPORT_TOLERANCE; -use crate::sync::network_context::RangeRequestId; use crate::sync::range_sync::RangeSyncType; -use beacon_chain::data_column_verification::CustodyDataColumn; -use beacon_chain::test_utils::{AttestationStrategy, BlockStrategy}; -use beacon_chain::{EngineState, NotifyExecutionLayer, block_verification_types::RpcBlock}; -use beacon_processor::WorkType; -use lighthouse_network::rpc::RequestType; -use lighthouse_network::rpc::methods::{ - BlobsByRangeRequest, DataColumnsByRangeRequest, OldBlocksByRangeRequest, - OldBlocksByRangeRequestV2, StatusMessageV2, -}; -use lighthouse_network::service::api_types::{ - AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, DataColumnsByRangeRequestId, - SyncRequestId, -}; +use lighthouse_network::rpc::RPCError; +use lighthouse_network::rpc::methods::StatusMessageV2; use lighthouse_network::{PeerId, SyncInfo}; -use std::time::Duration; -use types::{ - BlobSidecarList, BlockImportSource, Epoch, EthSpec, Hash256, MinimalEthSpec as E, - SignedBeaconBlock, SignedBeaconBlockHash, Slot, -}; +use types::{Epoch, EthSpec, Hash256, MinimalEthSpec as E, Slot}; -const D: Duration = Duration::new(0, 0); - -pub(crate) enum DataSidecars<E: EthSpec> { - Blobs(BlobSidecarList<E>), - DataColumns(Vec<CustodyDataColumn<E>>), -} - -enum ByRangeDataRequestIds { - PreDeneb, - PrePeerDAS(BlobsByRangeRequestId, PeerId), - PostPeerDAS(Vec<(DataColumnsByRangeRequestId, PeerId)>), -} - -/// Sync tests are usually written in the form: -/// - Do some action -/// - Expect a request to be sent -/// - Complete the above request -/// -/// To make writting tests succint, the machinery in this testing rig automatically identifies -/// _which_ request to complete. Picking the right request is critical for tests to pass, so this -/// filter allows better expressivity on the criteria to identify the right request. -#[derive(Default, Debug, Clone)] -struct RequestFilter { - peer: Option<PeerId>, - epoch: Option<u64>, -} - -impl RequestFilter { - fn peer(mut self, peer: PeerId) -> Self { - self.peer = Some(peer); - self - } - - fn epoch(mut self, epoch: u64) -> Self { - self.epoch = Some(epoch); - self - } -} - -fn filter() -> RequestFilter { - RequestFilter::default() -} +/// MinimalEthSpec has 8 slots per epoch +const SLOTS_PER_EPOCH: usize = 8; impl TestRig { - /// Produce a head peer with an advanced head fn add_head_peer(&mut self) -> PeerId { - self.add_head_peer_with_root(Hash256::random()) - } - - /// Produce a head peer with an advanced head - fn add_head_peer_with_root(&mut self, head_root: Hash256) -> PeerId { let local_info = self.local_info(); self.add_supernode_peer(SyncInfo { - head_root, + head_root: Hash256::random(), head_slot: local_info.head_slot + 1 + Slot::new(SLOT_IMPORT_TOLERANCE as u64), ..local_info }) } - // Produce a finalized peer with an advanced finalized epoch - fn add_finalized_peer(&mut self) -> PeerId { - self.add_finalized_peer_with_root(Hash256::random()) - } - - // Produce a finalized peer with an advanced finalized epoch - fn add_finalized_peer_with_root(&mut self, finalized_root: Hash256) -> PeerId { - let local_info = self.local_info(); - let finalized_epoch = local_info.finalized_epoch + 2; - self.add_supernode_peer(SyncInfo { - finalized_epoch, - finalized_root, - head_slot: finalized_epoch.start_slot(E::slots_per_epoch()), - head_root: Hash256::random(), - earliest_available_slot: None, - }) - } - fn finalized_remote_info_advanced_by(&self, advanced_epochs: Epoch) -> SyncInfo { let local_info = self.local_info(); let finalized_epoch = local_info.finalized_epoch + advanced_epochs; @@ -139,11 +79,7 @@ impl TestRig { } fn add_supernode_peer(&mut self, remote_info: SyncInfo) -> PeerId { - // Create valid peer known to network globals - // TODO(fulu): Using supernode peers to ensure we have peer across all column - // subnets for syncing. Should add tests connecting to full node peers. let peer_id = self.new_connected_supernode_peer(); - // Send peer to sync self.send_sync_message(SyncMessage::AddPeer(peer_id, remote_info)); peer_id } @@ -181,423 +117,362 @@ impl TestRig { ) } - #[track_caller] - fn expect_chain_segments(&mut self, count: usize) { - for i in 0..count { - self.pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::ChainSegment).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expect ChainSegment work event count {i}: {e:?}")); - } + // -- Setup helpers -- + + /// Head sync: peers whose finalized root/epoch match ours (known to fork choice), + /// but whose head is ahead. Only head chain is created. + async fn setup_head_sync(&mut self) { + self.build_chain(SLOTS_PER_EPOCH).await; + self.add_head_peer(); + self.assert_state(RangeSyncType::Head); } - fn update_execution_engine_state(&mut self, state: EngineState) { - self.log(&format!("execution engine state updated: {state:?}")); - self.sync_manager.update_execution_engine_state(state); + /// Finalized sync: peers whose finalized epoch is advanced and head == finalized start slot. + /// Returns the remote SyncInfo (needed for blacklist tests). + async fn setup_finalized_sync(&mut self) -> SyncInfo { + let advanced_epochs = 5; + self.build_chain(advanced_epochs * SLOTS_PER_EPOCH).await; + let remote_info = self.finalized_remote_info_advanced_by((advanced_epochs as u64).into()); + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info.clone()); + self.assert_state(RangeSyncType::Finalized); + remote_info } - fn find_blocks_by_range_request( - &mut self, - request_filter: RequestFilter, - ) -> ((BlocksByRangeRequestId, PeerId), ByRangeDataRequestIds) { - let filter_f = |peer: PeerId, start_slot: u64| { - if let Some(expected_epoch) = request_filter.epoch { - let epoch = Slot::new(start_slot).epoch(E::slots_per_epoch()).as_u64(); - if epoch != expected_epoch { - return false; - } - } - if let Some(expected_peer) = request_filter.peer - && peer != expected_peer - { - return false; - } - - true + /// Finalized-to-head: peers whose finalized is advanced AND head is beyond finalized. + /// After finalized sync completes, head chains are created from awaiting_head_peers. + async fn setup_finalized_and_head_sync(&mut self) { + let finalized_epochs = 5; + let head_epochs = 7; + self.build_chain(head_epochs * SLOTS_PER_EPOCH).await; + let local_info = self.local_info(); + let finalized_epoch = local_info.finalized_epoch + Epoch::new(finalized_epochs as u64); + let head_slot = Slot::new((head_epochs * SLOTS_PER_EPOCH) as u64); + let remote_info = SyncInfo { + finalized_epoch, + finalized_root: Hash256::random(), + head_slot, + head_root: Hash256::random(), + earliest_available_slot: None, }; - - let block_req = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - request: - RequestType::BlocksByRange(OldBlocksByRangeRequest::V2( - OldBlocksByRangeRequestV2 { start_slot, .. }, - )), - app_request_id: AppRequestId::Sync(SyncRequestId::BlocksByRange(id)), - } if filter_f(*peer_id, *start_slot) => Some((*id, *peer_id)), - _ => None, - }) - .unwrap_or_else(|e| { - panic!("Should have a BlocksByRange request, filter {request_filter:?}: {e:?}") - }); - - let by_range_data_requests = if self.after_fulu() { - let mut data_columns_requests = vec![]; - while let Ok(data_columns_request) = self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - request: - RequestType::DataColumnsByRange(DataColumnsByRangeRequest { - start_slot, .. - }), - app_request_id: AppRequestId::Sync(SyncRequestId::DataColumnsByRange(id)), - } if filter_f(*peer_id, *start_slot) => Some((*id, *peer_id)), - _ => None, - }) { - data_columns_requests.push(data_columns_request); - } - if data_columns_requests.is_empty() { - panic!("Found zero DataColumnsByRange requests, filter {request_filter:?}"); - } - ByRangeDataRequestIds::PostPeerDAS(data_columns_requests) - } else if self.after_deneb() { - let (id, peer) = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - request: RequestType::BlobsByRange(BlobsByRangeRequest { start_slot, .. }), - app_request_id: AppRequestId::Sync(SyncRequestId::BlobsByRange(id)), - } if filter_f(*peer_id, *start_slot) => Some((*id, *peer_id)), - _ => None, - }) - .unwrap_or_else(|e| { - panic!("Should have a blobs by range request, filter {request_filter:?}: {e:?}") - }); - ByRangeDataRequestIds::PrePeerDAS(id, peer) - } else { - ByRangeDataRequestIds::PreDeneb - }; - - (block_req, by_range_data_requests) + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info); + self.assert_state(RangeSyncType::Finalized); } - fn find_and_complete_blocks_by_range_request( - &mut self, - request_filter: RequestFilter, - ) -> RangeRequestId { - let ((blocks_req_id, block_peer), by_range_data_request_ids) = - self.find_blocks_by_range_request(request_filter); - - // Complete the request with a single stream termination - self.log(&format!( - "Completing BlocksByRange request {blocks_req_id:?} with empty stream" - )); - self.send_sync_message(SyncMessage::RpcBlock { - sync_request_id: SyncRequestId::BlocksByRange(blocks_req_id), - peer_id: block_peer, - beacon_block: None, - seen_timestamp: D, - }); - - match by_range_data_request_ids { - ByRangeDataRequestIds::PreDeneb => {} - ByRangeDataRequestIds::PrePeerDAS(id, peer_id) => { - // Complete the request with a single stream termination - self.log(&format!( - "Completing BlobsByRange request {id:?} with empty stream" - )); - self.send_sync_message(SyncMessage::RpcBlob { - sync_request_id: SyncRequestId::BlobsByRange(id), - peer_id, - blob_sidecar: None, - seen_timestamp: D, - }); - } - ByRangeDataRequestIds::PostPeerDAS(data_column_req_ids) => { - // Complete the request with a single stream termination - for (id, peer_id) in data_column_req_ids { - self.log(&format!( - "Completing DataColumnsByRange request {id:?} with empty stream" - )); - self.send_sync_message(SyncMessage::RpcDataColumn { - sync_request_id: SyncRequestId::DataColumnsByRange(id), - peer_id, - data_column: None, - seen_timestamp: D, - }); - } - } - } - - blocks_req_id.parent_request_id.requester + /// Finalized sync with only 1 fullnode peer (insufficient custody coverage). + /// Returns remote_info to pass to `add_remaining_finalized_peers`. + async fn setup_finalized_sync_insufficient_peers(&mut self) -> SyncInfo { + let advanced_epochs = 5; + self.build_chain(advanced_epochs * SLOTS_PER_EPOCH).await; + let remote_info = self.finalized_remote_info_advanced_by((advanced_epochs as u64).into()); + self.add_fullnode_peer(remote_info.clone()); + self.assert_state(RangeSyncType::Finalized); + remote_info } - fn find_and_complete_processing_chain_segment(&mut self, id: ChainSegmentProcessId) { - self.pop_received_processor_event(|ev| { - (ev.work_type() == WorkType::ChainSegment).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected chain segment work event: {e}")); - - self.log(&format!( - "Completing ChainSegment processing work {id:?} with success" - )); - self.send_sync_message(SyncMessage::BatchProcessed { - sync_type: id, - result: crate::sync::BatchProcessResult::Success { - sent_blocks: 8, - imported_blocks: 8, - }, - }); - } - - fn complete_and_process_range_sync_until( - &mut self, - last_epoch: u64, - request_filter: RequestFilter, - ) { - for epoch in 0..last_epoch { - // Note: In this test we can't predict the block peer - let id = - self.find_and_complete_blocks_by_range_request(request_filter.clone().epoch(epoch)); - if let RangeRequestId::RangeSync { batch_id, .. } = id { - assert_eq!(batch_id.as_u64(), epoch, "Unexpected batch_id"); - } else { - panic!("unexpected RangeRequestId {id:?}"); - } - - let id = match id { - RangeRequestId::RangeSync { chain_id, batch_id } => { - ChainSegmentProcessId::RangeBatchId(chain_id, batch_id) - } - RangeRequestId::BackfillSync { batch_id } => { - ChainSegmentProcessId::BackSyncBatchId(batch_id) - } - }; - - self.find_and_complete_processing_chain_segment(id); - if epoch < last_epoch - 1 { - self.assert_state(RangeSyncType::Finalized); - } else { - self.assert_no_chains_exist(); - self.assert_no_failed_chains(); - } - } - } - - async fn create_canonical_block(&mut self) -> (SignedBeaconBlock<E>, Option<DataSidecars<E>>) { - self.harness.advance_slot(); - - let block_root = self - .harness - .extend_chain( - 1, - BlockStrategy::OnCanonicalHead, - AttestationStrategy::AllValidators, - ) + /// Finalized sync where local node already has blocks up to `local_epochs`. + /// Triggers optimistic start: the chain tries to download a batch at the local head + /// epoch concurrently with sequential processing from the start. + async fn setup_finalized_sync_with_local_head(&mut self, local_epochs: usize) { + let target_epochs = local_epochs + 3; // target beyond local head + self.build_chain(target_epochs * SLOTS_PER_EPOCH).await; + self.import_blocks_up_to_slot((local_epochs * SLOTS_PER_EPOCH) as u64) .await; - - let store = &self.harness.chain.store; - let block = store.get_full_block(&block_root).unwrap().unwrap(); - let fork = block.fork_name_unchecked(); - - let data_sidecars = if fork.fulu_enabled() { - store - .get_data_columns(&block_root) - .unwrap() - .map(|columns| { - columns - .into_iter() - .map(CustodyDataColumn::from_asserted_custody) - .collect() - }) - .map(DataSidecars::DataColumns) - } else if fork.deneb_enabled() { - store - .get_blobs(&block_root) - .unwrap() - .blobs() - .map(DataSidecars::Blobs) - } else { - None - }; - - (block, data_sidecars) + let remote_info = self.finalized_remote_info_advanced_by((target_epochs as u64).into()); + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info); + self.assert_state(RangeSyncType::Finalized); } - async fn remember_block( - &mut self, - (block, data_sidecars): (SignedBeaconBlock<E>, Option<DataSidecars<E>>), - ) { - // This code is kind of duplicated from Harness::process_block, but takes sidecars directly. - let block_root = block.canonical_root(); - self.harness.set_current_slot(block.slot()); - let _: SignedBeaconBlockHash = self - .harness - .chain - .process_block( - block_root, - build_rpc_block(block.into(), &data_sidecars), - NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, - || Ok(()), - ) - .await - .unwrap() - .try_into() - .unwrap(); - self.harness.chain.recompute_head_at_current_slot().await; + /// Add enough peers to cover all custody columns (same chain as insufficient setup) + fn add_remaining_finalized_peers(&mut self, remote_info: SyncInfo) { + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info); + } + + // -- Assert helpers -- + + /// Assert range sync completed: chains created and removed, all blocks ingested, + /// finalized epoch advanced, no penalties, no leftover events. + fn assert_range_sync_completed(&mut self) { + self.assert_successful_range_sync(); + self.assert_no_failed_chains(); + assert_eq!( + self.head_slot(), + self.max_known_slot(), + "Head slot should match the last built block (all blocks ingested)" + ); + assert!( + self.finalized_epoch() > types::Epoch::new(0), + "Finalized epoch should have advanced past genesis, got {}", + self.finalized_epoch() + ); + self.assert_no_penalties(); + self.assert_empty_network(); + self.assert_empty_processor(); + } + + /// Assert head sync completed (no finalization expected for short ranges) + fn assert_head_sync_completed(&mut self) { + self.assert_successful_range_sync(); + self.assert_no_failed_chains(); + assert_eq!( + self.head_slot(), + self.max_known_slot(), + "Head slot should match the last built block (all blocks ingested)" + ); + self.assert_no_penalties(); + } + + /// Assert chain was removed and peers received faulty_chain penalty + fn assert_range_sync_chain_failed(&mut self) { + self.assert_no_chains_exist(); + assert!( + self.penalties.iter().any(|p| p.msg == "faulty_chain"), + "Expected faulty_chain penalty, got {:?}", + self.penalties + ); + } + + /// Assert range sync removed chains (e.g., all peers disconnected) + fn assert_range_sync_chain_removed(&mut self) { + self.assert_no_chains_exist(); + } + + /// Assert a new peer with a blacklisted root gets disconnected + fn assert_peer_blacklisted(&mut self, remote_info: SyncInfo) { + let new_peer = self.add_supernode_peer(remote_info); + self.pop_received_network_event(|ev| match ev { + NetworkMessage::GoodbyePeer { peer_id, .. } if *peer_id == new_peer => Some(()), + _ => None, + }) + .expect("Peer with blacklisted root should receive Goodbye"); } } -fn build_rpc_block( - block: Arc<SignedBeaconBlock<E>>, - data_sidecars: &Option<DataSidecars<E>>, -) -> RpcBlock<E> { - match data_sidecars { - Some(DataSidecars::Blobs(blobs)) => { - RpcBlock::new(None, block, Some(blobs.clone())).unwrap() - } - Some(DataSidecars::DataColumns(columns)) => { - RpcBlock::new_with_custody_columns(None, block, columns.clone()).unwrap() - } - // Block has no data, expects zero columns - None => RpcBlock::new_without_blobs(None, block), - } -} - -#[test] -fn head_chain_removed_while_finalized_syncing() { - // NOTE: this is a regression test. - // Added in PR https://github.com/sigp/lighthouse/pull/2821 - let mut rig = TestRig::test_setup(); - - // Get a peer with an advanced head - let head_peer = rig.add_head_peer(); - rig.assert_state(RangeSyncType::Head); - - // Sync should have requested a batch, grab the request. - let _ = rig.find_blocks_by_range_request(filter().peer(head_peer)); - - // Now get a peer with an advanced finalized epoch. - let finalized_peer = rig.add_finalized_peer(); - rig.assert_state(RangeSyncType::Finalized); - - // Sync should have requested a batch, grab the request - let _ = rig.find_blocks_by_range_request(filter().peer(finalized_peer)); - - // Fail the head chain by disconnecting the peer. - rig.peer_disconnected(head_peer); - rig.assert_state(RangeSyncType::Finalized); -} +// ============================================================================================ +// Tests +// ============================================================================================ +/// Head sync: single peer slightly ahead → download batches → all blocks ingested. #[tokio::test] -async fn state_update_while_purging() { - // NOTE: this is a regression test. - // Added in PR https://github.com/sigp/lighthouse/pull/2827 - let mut rig = TestRig::test_setup(); - - // Create blocks on a separate harness - let mut rig_2 = TestRig::test_setup(); - // Need to create blocks that can be inserted into the fork-choice and fit the "known - // conditions" below. - let head_peer_block = rig_2.create_canonical_block().await; - let head_peer_root = head_peer_block.0.canonical_root(); - let finalized_peer_block = rig_2.create_canonical_block().await; - let finalized_peer_root = finalized_peer_block.0.canonical_root(); - - // Get a peer with an advanced head - let head_peer = rig.add_head_peer_with_root(head_peer_root); - rig.assert_state(RangeSyncType::Head); - - // Sync should have requested a batch, grab the request. - let _ = rig.find_blocks_by_range_request(filter().peer(head_peer)); - - // Now get a peer with an advanced finalized epoch. - let finalized_peer = rig.add_finalized_peer_with_root(finalized_peer_root); - rig.assert_state(RangeSyncType::Finalized); - - // Sync should have requested a batch, grab the request - let _ = rig.find_blocks_by_range_request(filter().peer(finalized_peer)); - - // Now the chain knows both chains target roots. - rig.remember_block(head_peer_block).await; - rig.remember_block(finalized_peer_block).await; - - // Add an additional peer to the second chain to make range update it's status - rig.add_finalized_peer(); +async fn head_sync_completes() { + let mut r = TestRig::default(); + r.setup_head_sync().await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_head_sync_completed(); + r.assert_head_slot(SLOTS_PER_EPOCH as u64); } -#[test] -fn pause_and_resume_on_ee_offline() { - let mut rig = TestRig::test_setup(); - - // add some peers - let peer1 = rig.add_head_peer(); - // make the ee offline - rig.update_execution_engine_state(EngineState::Offline); - // send the response to the request - rig.find_and_complete_blocks_by_range_request(filter().peer(peer1).epoch(0)); - // the beacon processor shouldn't have received any work - rig.expect_empty_processor(); - - // while the ee is offline, more peers might arrive. Add a new finalized peer. - let _peer2 = rig.add_finalized_peer(); - - // send the response to the request - // Don't filter requests and the columns requests may be sent to peer1 or peer2 - // We need to filter by epoch, because the previous batch eagerly sent requests for the next - // epoch for the other batch. So we can either filter by epoch of by sync type. - rig.find_and_complete_blocks_by_range_request(filter().epoch(0)); - // the beacon processor shouldn't have received any work - rig.expect_empty_processor(); - // make the beacon processor available again. - // update_execution_engine_state implicitly calls resume - // now resume range, we should have two processing requests in the beacon processor. - rig.update_execution_engine_state(EngineState::Online); - - // The head chain and finalized chain (2) should be in the processing queue - rig.expect_chain_segments(2); +/// Peers with advanced finalized AND head beyond finalized. Finalized sync completes first, +/// then head chains are created from awaiting_head_peers to sync the remaining gap. +#[tokio::test] +async fn finalized_to_head_transition() { + let mut r = TestRig::default(); + r.setup_finalized_and_head_sync().await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); + r.assert_head_slot(7 * SLOTS_PER_EPOCH as u64); } -/// To attempt to finalize the peer's status finalized checkpoint we synced to its finalized epoch + -/// 2 epochs + 1 slot. -const EXTRA_SYNCED_EPOCHS: u64 = 2 + 1; - -#[test] -fn finalized_sync_enough_global_custody_peers_few_chain_peers() { - // Run for all forks - let mut r = TestRig::test_setup(); - - let advanced_epochs: u64 = 2; - let remote_info = r.finalized_remote_info_advanced_by(advanced_epochs.into()); - - // Generate enough peers and supernodes to cover all custody columns - let peer_count = 100; - r.add_fullnode_peers(remote_info.clone(), peer_count); - r.add_supernode_peer(remote_info); - r.assert_state(RangeSyncType::Finalized); - - let last_epoch = advanced_epochs + EXTRA_SYNCED_EPOCHS; - r.complete_and_process_range_sync_until(last_epoch, filter()); +/// Finalized sync happy path: all batches download and process, head advances to target, +/// finalized epoch advances past genesis. +#[tokio::test] +async fn finalized_sync_completes() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); + r.assert_head_slot(5 * SLOTS_PER_EPOCH as u64); } -#[test] -fn finalized_sync_not_enough_custody_peers_on_start() { - let mut r = TestRig::test_setup(); - // Only run post-PeerDAS +/// First BlocksByRange request gets an RPC error. Batch retries from another peer, +/// sync completes with no penalties (RPC errors are not penalized). +#[tokio::test] +async fn batch_rpc_error_retries() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().return_rpc_error(RPCError::UnsupportedProtocol)) + .await; + r.assert_range_sync_completed(); +} + +/// Peer returns zero blocks for a BlocksByRange request. Batch retries, sync completes. +#[tokio::test] +async fn batch_peer_returns_empty_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_no_range_blocks_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Peer returns zero columns for a DataColumnsByRange request. Batch retries, sync completes. +/// Only exercises column logic on fulu+. +#[tokio::test] +async fn batch_peer_returns_no_columns_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_no_range_columns_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Peer returns columns with indices it wasn't asked for → UnrequestedIndex verify error. +/// Batch retries from another peer, sync completes. +#[tokio::test] +async fn batch_peer_returns_wrong_column_indices_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_wrong_range_column_indices_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Peer returns columns from a slot outside the requested range → UnrequestedSlot verify error. +/// Batch retries from another peer, sync completes. +#[tokio::test] +async fn batch_peer_returns_wrong_column_slots_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_wrong_range_column_slots_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// PeerDAS: peer returns only half the requested columns. Block-sidecar coupling detects +/// missing columns → CouplingError::DataColumnPeerFailure → retry_partial_batch from other peers. +#[tokio::test] +async fn batch_peer_returns_partial_columns_then_succeeds() { + let mut r = TestRig::default(); if !r.fork_name.fulu_enabled() { return; } - - let advanced_epochs: u64 = 2; - let remote_info = r.finalized_remote_info_advanced_by(advanced_epochs.into()); - - // Unikely that the single peer we added has enough columns for us. Tests are deterministic and - // this error should never be hit - r.add_fullnode_peer(remote_info.clone()); - r.assert_state(RangeSyncType::Finalized); - - // Because we don't have enough peers on all columns we haven't sent any request. - // NOTE: There's a small chance that this single peer happens to custody exactly the set we - // expect, in that case the test will fail. Find a way to make the test deterministic. - r.expect_empty_network(); - - // Generate enough peers and supernodes to cover all custody columns - let peer_count = 100; - r.add_fullnode_peers(remote_info.clone(), peer_count); - r.add_supernode_peer(remote_info); - - let last_epoch = advanced_epochs + EXTRA_SYNCED_EPOCHS; - r.complete_and_process_range_sync_until(last_epoch, filter()); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_partial_range_columns_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Batch processing returns NonFaultyFailure (e.g. transient error). Batch goes back to +/// AwaitingDownload, retries without penalty, sync completes. +#[tokio::test] +async fn batch_non_faulty_failure_retries() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_non_faulty_failures(1)) + .await; + r.assert_range_sync_completed(); +} + +/// Batch processing returns FaultyFailure once. Peer penalized with "faulty_batch", +/// batch redownloaded from a different peer, sync completes. +#[tokio::test] +async fn batch_faulty_failure_redownloads() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(1)) + .await; + r.assert_successful_range_sync(); + r.assert_penalties_of_type("faulty_batch"); +} + +/// Batch processing fails MAX_BATCH_PROCESSING_ATTEMPTS (3) times with FaultyFailure. +/// Chain removed, all peers penalized with "faulty_chain". +#[tokio::test] +async fn batch_max_failures_removes_chain() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(3)) + .await; + r.assert_range_sync_chain_failed(); +} + +/// Chain fails via max faulty retries → finalized root added to failed_chains LRU. +/// A new peer advertising the same finalized root gets disconnected with GoodbyeReason. +#[tokio::test] +async fn failed_chain_blacklisted() { + let mut r = TestRig::default(); + let remote_info = r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(3)) + .await; + r.assert_range_sync_chain_failed(); + r.assert_peer_blacklisted(remote_info); +} + +/// All peers disconnect before any request is fulfilled → chain removed (EmptyPeerPool). +#[tokio::test] +async fn all_peers_disconnect_removes_chain() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_disconnect_after_range_requests(0)) + .await; + r.assert_range_sync_chain_removed(); +} + +/// Peers disconnect after 1 request is served. Remaining in-flight responses arrive +/// for a chain that no longer exists — verified as a no-op (no crash). +#[tokio::test] +async fn late_response_for_removed_chain() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_disconnect_after_range_requests(1)) + .await; + r.assert_range_sync_chain_removed(); +} + +/// Execution engine goes offline at sync start. Batch responses complete but processing +/// is paused. After 2 responses, EE comes back online, queued batches process, sync completes. +#[tokio::test] +async fn ee_offline_then_online_resumes_sync() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_ee_offline_for_n_range_responses(2)) + .await; + r.assert_range_sync_completed(); +} + +/// Local node already has blocks up to epoch 3. Finalized sync starts targeting epoch 6. +/// The chain uses optimistic start: downloads a batch at the local head epoch concurrently +/// with sequential processing from the start. All blocks ingested. +#[tokio::test] +async fn finalized_sync_with_local_head_partial() { + let mut r = TestRig::default(); + r.setup_finalized_sync_with_local_head(3).await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); +} + +/// Local node has all blocks except the last one. Finalized sync only needs to fill the +/// final gap. Tests optimistic start where local head is near the target. +#[tokio::test] +async fn finalized_sync_with_local_head_near_target() { + let mut r = TestRig::default(); + let target_epochs = 5; + let local_slots = (target_epochs * SLOTS_PER_EPOCH) - 1; // all blocks except last + r.build_chain(target_epochs * SLOTS_PER_EPOCH).await; + r.import_blocks_up_to_slot(local_slots as u64).await; + let remote_info = r.finalized_remote_info_advanced_by((target_epochs as u64).into()); + r.add_fullnode_peers(remote_info.clone(), 100); + r.add_supernode_peer(remote_info); + r.assert_state(RangeSyncType::Finalized); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); + r.assert_head_slot((target_epochs * SLOTS_PER_EPOCH) as u64); +} + +/// PeerDAS only: single fullnode peer doesn't cover all custody columns → no requests sent. +/// Once enough fullnodes + a supernode arrive, sync proceeds and completes. +#[tokio::test] +async fn not_enough_custody_peers_then_peers_arrive() { + let mut r = TestRig::default(); + if !r.fork_name.fulu_enabled() { + return; + } + let remote_info = r.setup_finalized_sync_insufficient_peers().await; + r.assert_empty_network(); + r.add_remaining_finalized_peers(remote_info); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); } diff --git a/beacon_node/operation_pool/src/attestation.rs b/beacon_node/operation_pool/src/attestation.rs index 897a7e5ecc..045adfebe0 100644 --- a/beacon_node/operation_pool/src/attestation.rs +++ b/beacon_node/operation_pool/src/attestation.rs @@ -8,8 +8,8 @@ use state_processing::common::{ use std::collections::HashMap; use types::{ Attestation, BeaconState, ChainSpec, EthSpec, - beacon_state::BeaconStateBase, consts::altair::{PARTICIPATION_FLAG_WEIGHTS, PROPOSER_WEIGHT, WEIGHT_DENOMINATOR}, + state::BeaconStateBase, }; pub const PROPOSER_REWARD_DENOMINATOR: u64 = diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index 00361450a5..de5fe9a098 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -23,10 +23,12 @@ use crate::attestation_storage::{AttestationMap, CheckpointKey}; use crate::bls_to_execution_changes::BlsToExecutionChanges; use crate::sync_aggregate_id::SyncAggregateId; use attester_slashing::AttesterSlashingMaxCover; +use bls::AggregateSignature; use max_cover::maximum_cover; use parking_lot::{RwLock, RwLockWriteGuard}; use rand::rng; use rand::seq::SliceRandom; +use ssz::BitVector; use state_processing::per_block_processing::errors::AttestationValidationError; use state_processing::per_block_processing::{ VerifySignatures, get_slashable_indices_modular, verify_exit, @@ -38,9 +40,10 @@ use std::ptr; use typenum::Unsigned; use types::{ AbstractExecPayload, Attestation, AttestationData, AttesterSlashing, BeaconState, - BeaconStateError, ChainSpec, Epoch, EthSpec, ProposerSlashing, SignedBeaconBlock, - SignedBlsToExecutionChange, SignedVoluntaryExit, Slot, SyncAggregate, - SyncCommitteeContribution, Validator, sync_aggregate::Error as SyncAggregateError, + BeaconStateError, ChainSpec, Epoch, EthSpec, Hash256, PayloadAttestation, + PayloadAttestationData, PayloadAttestationMessage, ProposerSlashing, SignedBeaconBlock, + SignedBlsToExecutionChange, SignedVoluntaryExit, Slot, SyncAggregate, SyncAggregateError, + SyncCommitteeContribution, Validator, }; type SyncContributions<E> = RwLock<HashMap<SyncAggregateId, Vec<SyncCommitteeContribution<E>>>>; @@ -59,6 +62,9 @@ pub struct OperationPool<E: EthSpec + Default> { voluntary_exits: RwLock<HashMap<u64, SigVerifiedOp<SignedVoluntaryExit, E>>>, /// Map from credential changing validator to their position in the queue. bls_to_execution_changes: RwLock<BlsToExecutionChanges<E>>, + /// Map from payload attestation data to individual messages for aggregation at block production. + payload_attestation_messages: + RwLock<HashMap<PayloadAttestationData, Vec<PayloadAttestationMessage>>>, /// Reward cache for accelerating attestation packing. reward_cache: RwLock<RewardCache>, _phantom: PhantomData<E>, @@ -78,6 +84,8 @@ pub enum OpPoolError { IncorrectOpPoolVariant, EpochCacheNotInitialized, EpochCacheError(EpochCacheError), + GetPtcError(BeaconStateError), + PayloadAttestationBitError, } #[derive(Default)] @@ -193,6 +201,100 @@ impl<E: EthSpec> OperationPool<E> { }); } + /// Insert a validated `PayloadAttestationMessage` into the pool. + pub fn insert_payload_attestation_message( + &self, + message: PayloadAttestationMessage, + ) -> Result<(), OpPoolError> { + let mut messages = self.payload_attestation_messages.write(); + let entry = messages.entry(message.data.clone()).or_default(); + if !entry + .iter() + .any(|m| m.validator_index == message.validator_index) + { + entry.push(message); + } + Ok(()) + } + + /// Build `PayloadAttestation`s from stored messages for block production. + /// + /// `parent_block_root` is the root of the parent block (the block PTC members attested to). + /// Returns one `PayloadAttestation` per distinct `PayloadAttestationData`. With two boolean + /// fields this yields at most 4, capped to `MaxPayloadAttestations`. + pub fn get_payload_attestations( + &self, + state: &BeaconState<E>, + parent_block_root: Hash256, + spec: &ChainSpec, + ) -> Result<Vec<PayloadAttestation<E>>, OpPoolError> { + let target_slot = state.slot().saturating_sub(1u64); + + let ptc = state + .get_ptc(target_slot, spec) + .map_err(OpPoolError::GetPtcError)?; + + let messages = self.payload_attestation_messages.read(); + let mut result = Vec::new(); + + for (data, msgs) in messages.iter() { + if data.slot != target_slot || data.beacon_block_root != parent_block_root { + continue; + } + + let mut aggregation_bits = BitVector::new(); + let mut aggregate_sig = AggregateSignature::infinity(); + + for msg in msgs { + if let Some(pos) = ptc + .0 + .iter() + .position(|&idx| idx == msg.validator_index as usize) + && !aggregation_bits.get(pos).unwrap_or(false) + { + aggregation_bits + .set(pos, true) + .map_err(|_| OpPoolError::PayloadAttestationBitError)?; + aggregate_sig.add_assign(&msg.signature); + } + } + + if aggregation_bits.num_set_bits() > 0 { + result.push(PayloadAttestation { + aggregation_bits, + data: data.clone(), + signature: aggregate_sig, + }); + } + } + + // Prefer most participation and cap by `max_payload_attestations` + result.sort_by(|a, b| { + b.aggregation_bits + .num_set_bits() + .cmp(&a.aggregation_bits.num_set_bits()) + }); + result.truncate(E::max_payload_attestations()); + + Ok(result) + } + + /// Remove payload attestation messages that are too old for block inclusion. + pub fn prune_payload_attestation_messages(&self, current_slot: Slot) { + self.payload_attestation_messages + .write() + .retain(|data, _| current_slot <= data.slot.saturating_add(Slot::new(1))); + } + + /// Total number of payload attestation messages in the pool. + pub fn num_payload_attestation_messages(&self) -> usize { + self.payload_attestation_messages + .read() + .values() + .map(|msgs| msgs.len()) + .sum() + } + /// Insert an attestation into the pool, aggregating it with existing attestations if possible. /// /// ## Note @@ -646,6 +748,7 @@ impl<E: EthSpec> OperationPool<E> { ) { self.prune_attestations(current_epoch); self.prune_sync_contributions(head_state.slot()); + self.prune_payload_attestation_messages(head_state.slot()); self.prune_proposer_slashings(finalized_state); self.prune_attester_slashings(finalized_state); self.prune_voluntary_exits(finalized_state, spec); @@ -1148,7 +1251,7 @@ mod release_tests { }) .collect::<Vec<_>>(); - for att in aggs1.into_iter().chain(aggs2.into_iter()) { + for att in aggs1.into_iter().chain(aggs2) { let attesting_indices = get_attesting_indices_from_state(&state, att.to_ref()).unwrap(); op_pool.insert_attestation(att, attesting_indices).unwrap(); @@ -1851,8 +1954,11 @@ mod release_tests { let mut spec = E::default_spec(); // Give some room to sign surround slashings. - spec.altair_fork_epoch = Some(Epoch::new(3)); - spec.bellatrix_fork_epoch = Some(Epoch::new(6)); + spec.altair_fork_epoch = Some(Epoch::new(0)); + spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + spec.capella_fork_epoch = Some(Epoch::new(0)); + spec.deneb_fork_epoch = Some(Epoch::new(2)); + spec.electra_fork_epoch = Some(Epoch::new(4)); // To make exits immediately valid. spec.shard_committee_period = 0; @@ -1860,185 +1966,114 @@ mod release_tests { let num_validators = 32; let harness = get_harness::<E>(num_validators, Some(spec.clone())); + if let Some(mock_el) = harness.mock_execution_layer.as_ref() { + mock_el.server.all_payloads_valid(); + } (harness, spec) } - /// Test several cross-fork voluntary exits: - /// - /// - phase0 exit (not valid after Bellatrix) - /// - phase0 exit signed with Altair fork version (only valid after Bellatrix) - #[tokio::test] - async fn cross_fork_exits() { - let (harness, spec) = cross_fork_harness::<MainnetEthSpec>(); - let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); - let bellatrix_fork_epoch = spec.bellatrix_fork_epoch.unwrap(); - let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); - - let op_pool = OperationPool::<MainnetEthSpec>::new(); - - // Sign an exit in phase0 with a phase0 epoch. - let exit1 = harness.make_voluntary_exit(0, Epoch::new(0)); - - // Advance to Altair. - harness - .extend_to_slot(altair_fork_epoch.start_slot(slots_per_epoch)) - .await; - let altair_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!(altair_head.beacon_state.current_epoch(), altair_fork_epoch); - - // Add exit 1 to the op pool during Altair. It's still valid at this point and should be - // returned. - let verified_exit1 = exit1 - .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) - .unwrap(); - op_pool.insert_voluntary_exit(verified_exit1); - let exits = - op_pool.get_voluntary_exits(&altair_head.beacon_state, |_| true, &harness.chain.spec); - assert!(exits.contains(&exit1)); - assert_eq!(exits.len(), 1); - - // Advance to Bellatrix. - harness - .extend_to_slot(bellatrix_fork_epoch.start_slot(slots_per_epoch)) - .await; - let bellatrix_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!( - bellatrix_head.beacon_state.current_epoch(), - bellatrix_fork_epoch - ); - - // Sign an exit with the Altair domain and a phase0 epoch. This is a weird type of exit - // that is valid because after the Bellatrix fork we'll use the Altair fork domain to verify - // all prior epochs. - let unsigned_exit = VoluntaryExit { - epoch: Epoch::new(0), - validator_index: 2, - }; - let exit2 = SignedVoluntaryExit { - message: unsigned_exit.clone(), - signature: harness.validator_keypairs[2] - .sk - .sign(unsigned_exit.signing_root(spec.compute_domain( - Domain::VoluntaryExit, - harness.spec.altair_fork_version, - harness.chain.genesis_validators_root, - ))), - }; - - let verified_exit2 = exit2 - .clone() - .validate(&bellatrix_head.beacon_state, &harness.chain.spec) - .unwrap(); - op_pool.insert_voluntary_exit(verified_exit2); - - // Attempting to fetch exit1 now should fail, despite it still being in the pool. - // exit2 should still be valid, because it was signed with the Altair fork domain. - assert_eq!(op_pool.voluntary_exits.read().len(), 2); - let exits = - op_pool.get_voluntary_exits(&bellatrix_head.beacon_state, |_| true, &harness.spec); - assert_eq!(&exits, &[exit2]); - } + // Voluntary exits signed post-Capella are perpetually valid across forks, so no + // cross-fork test is required here. /// Test several cross-fork proposer slashings: /// - /// - phase0 slashing (not valid after Bellatrix) - /// - Bellatrix signed with Altair fork version (not valid after Bellatrix) - /// - phase0 exit signed with Altair fork version (only valid after Bellatrix) + /// - Capella slashing (not valid after Electra) + /// - Electra signed with Deneb fork version (not valid after Electra) + /// - Capella exit signed with Deneb fork version (only valid after Electra) #[tokio::test] async fn cross_fork_proposer_slashings() { let (harness, spec) = cross_fork_harness::<MainnetEthSpec>(); let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); - let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); - let bellatrix_fork_epoch = spec.bellatrix_fork_epoch.unwrap(); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(slots_per_epoch); + let deneb_fork_epoch = spec.deneb_fork_epoch.unwrap(); + let electra_fork_epoch = spec.electra_fork_epoch.unwrap(); + let electra_fork_slot = electra_fork_epoch.start_slot(slots_per_epoch); let op_pool = OperationPool::<MainnetEthSpec>::new(); - // Sign a proposer slashing in phase0 with a phase0 epoch. + // Sign a proposer slashing in Capella with a Capella slot. let slashing1 = harness.make_proposer_slashing_at_slot(0, Some(Slot::new(1))); - // Advance to Altair. + // Advance to Deneb. harness - .extend_to_slot(altair_fork_epoch.start_slot(slots_per_epoch)) + .extend_to_slot(deneb_fork_epoch.start_slot(slots_per_epoch)) .await; - let altair_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!(altair_head.beacon_state.current_epoch(), altair_fork_epoch); + let deneb_head = harness.chain.canonical_head.cached_head().snapshot; + assert_eq!(deneb_head.beacon_state.current_epoch(), deneb_fork_epoch); - // Add slashing1 to the op pool during Altair. It's still valid at this point and should be + // Add slashing1 to the op pool during Deneb. It's still valid at this point and should be // returned. let verified_slashing1 = slashing1 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_proposer_slashing(verified_slashing1); let (proposer_slashings, _, _) = - op_pool.get_slashings_and_exits(&altair_head.beacon_state, &harness.chain.spec); + op_pool.get_slashings_and_exits(&deneb_head.beacon_state, &harness.chain.spec); assert!(proposer_slashings.contains(&slashing1)); assert_eq!(proposer_slashings.len(), 1); - // Sign a proposer slashing with a Bellatrix slot using the Altair fork domain. + // Sign a proposer slashing with a Electra slot using the Deneb fork domain. // - // This slashing is valid only before the Bellatrix fork epoch. - let slashing2 = harness.make_proposer_slashing_at_slot(1, Some(bellatrix_fork_slot)); + // This slashing is valid only before the Electra fork epoch. + let slashing2 = harness.make_proposer_slashing_at_slot(1, Some(electra_fork_slot)); let verified_slashing2 = slashing2 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_proposer_slashing(verified_slashing2); let (proposer_slashings, _, _) = - op_pool.get_slashings_and_exits(&altair_head.beacon_state, &harness.chain.spec); + op_pool.get_slashings_and_exits(&deneb_head.beacon_state, &harness.chain.spec); assert!(proposer_slashings.contains(&slashing1)); assert!(proposer_slashings.contains(&slashing2)); assert_eq!(proposer_slashings.len(), 2); - // Advance to Bellatrix. - harness.extend_to_slot(bellatrix_fork_slot).await; - let bellatrix_head = harness.chain.canonical_head.cached_head().snapshot; + // Advance to Electra. + harness.extend_to_slot(electra_fork_slot).await; + let electra_head = harness.chain.canonical_head.cached_head().snapshot; assert_eq!( - bellatrix_head.beacon_state.current_epoch(), - bellatrix_fork_epoch + electra_head.beacon_state.current_epoch(), + electra_fork_epoch ); - // Sign a proposer slashing with the Altair domain and a phase0 slot. This is a weird type - // of slashing that is only valid after the Bellatrix fork because we'll use the Altair fork + // Sign a proposer slashing with the Deneb domain and a Capella slot. This is a weird type + // of slashing that is only valid after the Electra fork because we'll use the Deneb fork // domain to verify all prior epochs. let slashing3 = harness.make_proposer_slashing_at_slot(2, Some(Slot::new(1))); let verified_slashing3 = slashing3 .clone() - .validate(&bellatrix_head.beacon_state, &harness.chain.spec) + .validate(&electra_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_proposer_slashing(verified_slashing3); // Attempting to fetch slashing1 now should fail, despite it still being in the pool. // Likewise slashing2 is also invalid now because it should be signed with the - // Bellatrix fork version. - // slashing3 should still be valid, because it was signed with the Altair fork domain. + // Electra fork version. + // slashing3 should still be valid, because it was signed with the Deneb fork domain. assert_eq!(op_pool.proposer_slashings.read().len(), 3); let (proposer_slashings, _, _) = - op_pool.get_slashings_and_exits(&bellatrix_head.beacon_state, &harness.spec); + op_pool.get_slashings_and_exits(&electra_head.beacon_state, &harness.spec); assert!(proposer_slashings.contains(&slashing3)); assert_eq!(proposer_slashings.len(), 1); } /// Test several cross-fork attester slashings: /// - /// - both target epochs in phase0 (not valid after Bellatrix) - /// - both target epochs in Bellatrix but signed with Altair domain (not valid after Bellatrix) - /// - Altair attestation that surrounds a phase0 attestation (not valid after Bellatrix) - /// - both target epochs in phase0 but signed with Altair domain (only valid after Bellatrix) + /// - both target epochs in Capella (not valid after Electra) + /// - both target epochs in Electra but signed with Deneb domain (not valid after Electra) + /// - Deneb attestation that surrounds a Capella attestation (not valid after Electra) + /// - both target epochs in Capella but signed with Deneb domain (only valid after Electra) #[tokio::test] async fn cross_fork_attester_slashings() { let (harness, spec) = cross_fork_harness::<MainnetEthSpec>(); let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); let zero_epoch = Epoch::new(0); - let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); - let bellatrix_fork_epoch = spec.bellatrix_fork_epoch.unwrap(); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(slots_per_epoch); + let deneb_fork_epoch = spec.deneb_fork_epoch.unwrap(); + let electra_fork_epoch = spec.electra_fork_epoch.unwrap(); + let electra_fork_slot = electra_fork_epoch.start_slot(slots_per_epoch); let op_pool = OperationPool::<MainnetEthSpec>::new(); - // Sign an attester slashing with the phase0 fork version, with both target epochs in phase0. + // Sign an attester slashing with the Capella fork version, with both target epochs in Capella. let slashing1 = harness.make_attester_slashing_with_epochs( vec![0], None, @@ -2047,55 +2082,55 @@ mod release_tests { Some(zero_epoch), ); - // Advance to Altair. + // Advance to Deneb. harness - .extend_to_slot(altair_fork_epoch.start_slot(slots_per_epoch)) + .extend_to_slot(deneb_fork_epoch.start_slot(slots_per_epoch)) .await; - let altair_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!(altair_head.beacon_state.current_epoch(), altair_fork_epoch); + let deneb_head = harness.chain.canonical_head.cached_head().snapshot; + assert_eq!(deneb_head.beacon_state.current_epoch(), deneb_fork_epoch); - // Add slashing1 to the op pool during Altair. It's still valid at this point and should be + // Add slashing1 to the op pool during Deneb. It's still valid at this point and should be // returned. let verified_slashing1 = slashing1 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing1); - // Sign an attester slashing with two Bellatrix epochs using the Altair fork domain. + // Sign an attester slashing with two Electra epochs using the Deneb fork domain. // - // This slashing is valid only before the Bellatrix fork epoch. + // This slashing is valid only before the Electra fork epoch. let slashing2 = harness.make_attester_slashing_with_epochs( vec![1], None, - Some(bellatrix_fork_epoch), + Some(electra_fork_epoch), None, - Some(bellatrix_fork_epoch), + Some(electra_fork_epoch), ); let verified_slashing2 = slashing2 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing2); let (_, attester_slashings, _) = - op_pool.get_slashings_and_exits(&altair_head.beacon_state, &harness.chain.spec); + op_pool.get_slashings_and_exits(&deneb_head.beacon_state, &harness.chain.spec); assert!(attester_slashings.contains(&slashing1)); assert!(attester_slashings.contains(&slashing2)); assert_eq!(attester_slashings.len(), 2); - // Sign an attester slashing where an Altair attestation surrounds a phase0 one. + // Sign an attester slashing where a Deneb attestation surrounds a Capella one. // - // This slashing is valid only before the Bellatrix fork epoch. + // This slashing is valid only before the Electra fork epoch. let slashing3 = harness.make_attester_slashing_with_epochs( vec![2], Some(Epoch::new(0)), - Some(altair_fork_epoch), + Some(deneb_fork_epoch), Some(Epoch::new(1)), - Some(altair_fork_epoch - 1), + Some(deneb_fork_epoch - 1), ); let verified_slashing3 = slashing3 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing3); @@ -2104,44 +2139,253 @@ mod release_tests { // slashed. let mut to_be_slashed = hashset! {0}; let attester_slashings = - op_pool.get_attester_slashings(&altair_head.beacon_state, &mut to_be_slashed); + op_pool.get_attester_slashings(&deneb_head.beacon_state, &mut to_be_slashed); assert!(attester_slashings.contains(&slashing2)); assert!(attester_slashings.contains(&slashing3)); assert_eq!(attester_slashings.len(), 2); - // Advance to Bellatrix. - harness.extend_to_slot(bellatrix_fork_slot).await; - let bellatrix_head = harness.chain.canonical_head.cached_head().snapshot; + // Advance to Electra + harness.extend_to_slot(electra_fork_slot).await; + let electra_head = harness.chain.canonical_head.cached_head().snapshot; assert_eq!( - bellatrix_head.beacon_state.current_epoch(), - bellatrix_fork_epoch + electra_head.beacon_state.current_epoch(), + electra_fork_epoch ); - // Sign an attester slashing with the Altair domain and phase0 epochs. This is a weird type - // of slashing that is only valid after the Bellatrix fork because we'll use the Altair fork - // domain to verify all prior epochs. + // Sign an attester slashing with the Deneb domain and Capella epochs. This is only valid + // after the Electra fork. let slashing4 = harness.make_attester_slashing_with_epochs( vec![3], Some(Epoch::new(0)), - Some(altair_fork_epoch - 1), + Some(deneb_fork_epoch - 1), Some(Epoch::new(0)), - Some(altair_fork_epoch - 1), + Some(deneb_fork_epoch - 1), ); let verified_slashing4 = slashing4 .clone() - .validate(&bellatrix_head.beacon_state, &harness.chain.spec) + .validate(&electra_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing4); // All slashings except slashing4 are now invalid (despite being present in the pool). assert_eq!(op_pool.attester_slashings.read().len(), 4); let (_, attester_slashings, _) = - op_pool.get_slashings_and_exits(&bellatrix_head.beacon_state, &harness.spec); + op_pool.get_slashings_and_exits(&electra_head.beacon_state, &harness.spec); assert!(attester_slashings.contains(&slashing4)); assert_eq!(attester_slashings.len(), 1); // Pruning the attester slashings should remove all but slashing4. - op_pool.prune_attester_slashings(&bellatrix_head.beacon_state); + op_pool.prune_attester_slashings(&electra_head.beacon_state); assert_eq!(op_pool.attester_slashings.read().len(), 1); } + + fn make_payload_attestation_message( + slot: Slot, + validator_index: u64, + beacon_block_root: Hash256, + ) -> PayloadAttestationMessage { + make_payload_attestation_message_with_flags( + slot, + validator_index, + beacon_block_root, + true, + true, + ) + } + + fn make_payload_attestation_message_with_flags( + slot: Slot, + validator_index: u64, + beacon_block_root: Hash256, + payload_present: bool, + blob_data_available: bool, + ) -> PayloadAttestationMessage { + PayloadAttestationMessage { + validator_index, + data: PayloadAttestationData { + beacon_block_root, + slot, + payload_present, + blob_data_available, + }, + signature: bls::Signature::empty(), + } + } + + #[test] + fn payload_attestation_insert_and_dedup() { + let op_pool = OperationPool::<MinimalEthSpec>::new(); + let root = Hash256::repeat_byte(0xaa); + let slot = Slot::new(1); + + let msg1 = make_payload_attestation_message(slot, 0, root); + let msg2 = make_payload_attestation_message(slot, 1, root); + let msg1_dup = make_payload_attestation_message(slot, 0, root); + + op_pool.insert_payload_attestation_message(msg1).unwrap(); + op_pool.insert_payload_attestation_message(msg2).unwrap(); + op_pool + .insert_payload_attestation_message(msg1_dup) + .unwrap(); + + assert_eq!(op_pool.num_payload_attestation_messages(), 2); + } + + #[test] + fn payload_attestation_prune() { + let op_pool = OperationPool::<MinimalEthSpec>::new(); + let root = Hash256::repeat_byte(0xaa); + + let msg_slot1 = make_payload_attestation_message(Slot::new(1), 0, root); + let msg_slot2 = make_payload_attestation_message(Slot::new(2), 1, root); + let msg_slot3 = make_payload_attestation_message(Slot::new(3), 2, root); + + op_pool + .insert_payload_attestation_message(msg_slot1) + .unwrap(); + op_pool + .insert_payload_attestation_message(msg_slot2) + .unwrap(); + op_pool + .insert_payload_attestation_message(msg_slot3) + .unwrap(); + + assert_eq!(op_pool.num_payload_attestation_messages(), 3); + + op_pool.prune_payload_attestation_messages(Slot::new(3)); + assert_eq!(op_pool.num_payload_attestation_messages(), 2); + + op_pool.prune_payload_attestation_messages(Slot::new(4)); + assert_eq!(op_pool.num_payload_attestation_messages(), 1); + + op_pool.prune_payload_attestation_messages(Slot::new(5)); + assert_eq!(op_pool.num_payload_attestation_messages(), 0); + } + + #[tokio::test] + async fn payload_attestation_packs_bits_from_ptc_positions() { + let spec = test_spec::<MinimalEthSpec>(); + if spec.gloas_fork_epoch.is_none() { + return; + }; + + let num_validators = 64; + let harness = get_harness::<MinimalEthSpec>(num_validators, Some(spec.clone())); + + harness + .add_attested_blocks_at_slots( + harness.get_current_state(), + Hash256::zero(), + &[Slot::new(1)], + (0..num_validators).collect::<Vec<_>>().as_slice(), + ) + .await; + + let head = harness.chain.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + assert_eq!(state.slot(), Slot::new(1)); + + let target_slot = Slot::new(1); + let parent_root = head.head_block_root(); + let ptc = state.get_ptc(target_slot, &spec).unwrap(); + let ptc_member_0 = ptc.0[0] as u64; + let ptc_member_1 = ptc.0[1] as u64; + + let op_pool = OperationPool::<MinimalEthSpec>::new(); + + let msg0 = make_payload_attestation_message(target_slot, ptc_member_0, parent_root); + let msg1 = make_payload_attestation_message(target_slot, ptc_member_1, parent_root); + op_pool.insert_payload_attestation_message(msg0).unwrap(); + op_pool.insert_payload_attestation_message(msg1).unwrap(); + + // Advance state to slot 2 so get_payload_attestations looks at slot 1. + let mut advanced_state = state.clone(); + state_processing::state_advance::complete_state_advance( + &mut advanced_state, + None, + Slot::new(2), + &spec, + ) + .unwrap(); + + let attestations = op_pool + .get_payload_attestations(&advanced_state, parent_root, &spec) + .unwrap(); + + assert_eq!(attestations.len(), 1); + assert_eq!(attestations[0].aggregation_bits.num_set_bits(), 2); + assert!(attestations[0].aggregation_bits.get(0).unwrap()); + assert!(attestations[0].aggregation_bits.get(1).unwrap()); + assert!(attestations[0].data.payload_present); + } + + #[tokio::test] + async fn payload_attestation_multiple_data_combos_capped() { + let spec = test_spec::<MinimalEthSpec>(); + if spec.gloas_fork_epoch.is_none() { + return; + }; + + let num_validators = 64; + let harness = get_harness::<MinimalEthSpec>(num_validators, Some(spec.clone())); + + harness + .add_attested_blocks_at_slots( + harness.get_current_state(), + Hash256::zero(), + &[Slot::new(1)], + (0..num_validators).collect::<Vec<_>>().as_slice(), + ) + .await; + + let head = harness.chain.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let target_slot = Slot::new(1); + let parent_root = head.head_block_root(); + let ptc = state.get_ptc(target_slot, &spec).unwrap(); + + let op_pool = OperationPool::<MinimalEthSpec>::new(); + + // Given: PTC members vote with all 4 boolean combos, with varying participation. + let combos: [(bool, bool, &[usize]); 4] = [ + (true, true, &[0, 1, 2]), + (true, false, &[3, 4]), + (false, true, &[5]), + (false, false, &[6]), + ]; + for (payload_present, blob_available, positions) in &combos { + for &pos in *positions { + let validator_index = ptc.0[pos] as u64; + let msg = make_payload_attestation_message_with_flags( + target_slot, + validator_index, + parent_root, + *payload_present, + *blob_available, + ); + op_pool.insert_payload_attestation_message(msg).unwrap(); + } + } + + // When: we pack attestations for block production at slot 2. + let mut advanced_state = state.clone(); + state_processing::state_advance::complete_state_advance( + &mut advanced_state, + None, + Slot::new(2), + &spec, + ) + .unwrap(); + let attestations = op_pool + .get_payload_attestations(&advanced_state, parent_root, &spec) + .unwrap(); + + // Then: one attestation per combo, sorted by participation (most first). + assert_eq!(attestations.len(), 4); + let bit_counts: Vec<_> = attestations + .iter() + .map(|a| a.aggregation_bits.num_set_bits()) + .collect(); + assert_eq!(bit_counts, vec![3, 2, 1, 1]); + } } diff --git a/beacon_node/operation_pool/src/persistence.rs b/beacon_node/operation_pool/src/persistence.rs index 241b5fec53..56aafc27fe 100644 --- a/beacon_node/operation_pool/src/persistence.rs +++ b/beacon_node/operation_pool/src/persistence.rs @@ -209,6 +209,7 @@ impl<E: EthSpec> PersistedOperationPool<E> { proposer_slashings, voluntary_exits, bls_to_execution_changes: RwLock::new(bls_to_execution_changes), + payload_attestation_messages: Default::default(), reward_cache: Default::default(), _phantom: Default::default(), }; diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index e4c7c6ff1f..51cda0fac3 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -364,7 +364,7 @@ pub fn cli_app() -> Command { .long("libp2p-addresses") .value_name("MULTIADDR") .help("One or more comma-delimited multiaddrs to manually connect to a libp2p peer \ - without an ENR.") + without an ENR. DEPRECATED. The --libp2p-addresses flag is deprecated and replaced by --boot-nodes") .action(ArgAction::Set) .display_order(0) ) @@ -670,6 +670,15 @@ pub fn cli_app() -> Command { .hide(true) .display_order(0) ) + .arg( + Arg::new("enable-partial-columns") + .long("enable-partial-columns") + .help("Enable partial messages for data columns. This can reduce the amount of \ + data sent over the network.") + .action(ArgAction::SetTrue) + .help_heading(FLAG_HEADER) + .display_order(0) + ) /* * Monitoring metrics */ @@ -1246,9 +1255,12 @@ pub fn cli_app() -> Command { .display_order(0) ) .arg( - Arg::new("reconstruct-historic-states") - .long("reconstruct-historic-states") - .help("After a checkpoint sync, reconstruct historic states in the database. This requires syncing all the way back to genesis.") + Arg::new("archive") + .long("archive") + .alias("reconstruct-historic-states") + .help("Store all beacon states in the database. When checkpoint syncing, \ + states are reconstructed after backfill completes. This requires \ + syncing all the way back to genesis.") .action(ArgAction::SetTrue) .help_heading(FLAG_HEADER) .display_order(0) @@ -1404,6 +1416,16 @@ pub fn cli_app() -> Command { .help_heading(FLAG_HEADER) .display_order(0) ) + .arg( + Arg::new("ignore-ws-check") + .long("ignore-ws-check") + .help("Using this flag allows a node to run in a state that may expose it to long-range attacks. \ + For more information please read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity \ + If you understand the risks, you can use this flag to disable the Weak Subjectivity check at startup.") + .action(ArgAction::SetTrue) + .help_heading(FLAG_HEADER) + .display_order(0) + ) .arg( Arg::new("builder-fallback-skips") .long("builder-fallback-skips") diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 26dd3b6642..8ba2c0f321 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -15,7 +15,7 @@ use directory::{DEFAULT_BEACON_NODE_DIR, DEFAULT_NETWORK_DIR, DEFAULT_ROOT_DIR}; use environment::RuntimeContext; use execution_layer::DEFAULT_JWT_FILE; use http_api::TlsConfig; -use lighthouse_network::{Enr, Multiaddr, NetworkConfig, PeerIdSerialized, multiaddr::Protocol}; +use lighthouse_network::{Enr, Multiaddr, NetworkConfig, PeerIdSerialized}; use network_utils::listen_addr::ListenAddress; use sensitive_url::SensitiveUrl; use std::collections::HashSet; @@ -28,7 +28,7 @@ use std::num::NonZeroU16; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use types::graffiti::GraffitiString; use types::{Checkpoint, Epoch, EthSpec, Hash256}; @@ -110,6 +110,21 @@ pub fn get_config<E: EthSpec>( set_network_config(&mut client_config.network, cli_args, &data_dir_ref)?; + if parse_flag(cli_args, "enable-partial-columns") { + // Partial messages assume that each subnet maps to exactly one column. + // Check this here to avoid weird issues on networks where this is not the case. + if spec.data_column_sidecar_subnet_count == E::number_of_columns() as u64 { + client_config.network.enable_partial_columns = true; + client_config.chain.enable_partial_columns = true; + } else { + warn!( + subnets = spec.data_column_sidecar_subnet_count, + columns = E::number_of_columns(), + "Not enabling partial columns on networks with multiple columns per subnet" + ) + } + } + // Parse custody mode from CLI flags let is_supernode = parse_flag(cli_args, "supernode"); let is_semi_supernode = parse_flag(cli_args, "semi-supernode"); @@ -554,8 +569,8 @@ pub fn get_config<E: EthSpec>( ClientGenesis::DepositContract }; - if cli_args.get_flag("reconstruct-historic-states") { - client_config.chain.reconstruct_historic_states = true; + if cli_args.get_flag("archive") { + client_config.chain.archive = true; client_config.chain.genesis_backfill = true; } @@ -766,10 +781,7 @@ pub fn get_config<E: EthSpec>( client_config.chain.prepare_payload_lookahead = clap_utils::parse_optional(cli_args, "prepare-payload-lookahead")? .map(Duration::from_millis) - .unwrap_or_else(|| { - Duration::from_secs(spec.seconds_per_slot) - / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR - }); + .unwrap_or_else(|| spec.get_slot_duration() / DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR); client_config.chain.always_prepare_payload = cli_args.get_flag("always-prepare-payload"); @@ -783,6 +795,8 @@ pub fn get_config<E: EthSpec>( client_config.chain.paranoid_block_proposal = cli_args.get_flag("paranoid-block-proposal"); + client_config.chain.ignore_ws_check = cli_args.get_flag("ignore-ws-check"); + /* * Builder fallback configs. */ @@ -1196,12 +1210,6 @@ pub fn set_network_config( let multi: Multiaddr = addr .parse() .map_err(|_| format!("Not valid as ENR nor Multiaddr: {}", addr))?; - if !multi.iter().any(|proto| matches!(proto, Protocol::Udp(_))) { - error!(multiaddr = multi.to_string(), "Missing UDP in Multiaddr"); - } - if !multi.iter().any(|proto| matches!(proto, Protocol::P2p(_))) { - error!(multiaddr = multi.to_string(), "Missing P2P in Multiaddr"); - } multiaddrs.push(multi); } } @@ -1210,7 +1218,9 @@ pub fn set_network_config( config.boot_nodes_multiaddr = multiaddrs; } + // DEPRECATED: can be removed in v8.2.0./v9.0.0 if let Some(libp2p_addresses_str) = cli_args.get_one::<String>("libp2p-addresses") { + warn!("The --libp2p-addresses flag is deprecated and replaced by --boot-nodes"); config.libp2p_nodes = libp2p_addresses_str .split(',') .map(|multiaddr| { diff --git a/beacon_node/src/lib.rs b/beacon_node/src/lib.rs index 148ae464cf..0306dbc494 100644 --- a/beacon_node/src/lib.rs +++ b/beacon_node/src/lib.rs @@ -22,14 +22,9 @@ use types::{ChainSpec, Epoch, EthSpec, ForkName}; pub type ProductionClient<E> = Client<Witness<SystemTimeSlotClock, E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>>; -/// The beacon node `Client` that will be used in production. +/// The beacon node `Client` that is used in production. /// /// Generic over some `EthSpec`. -/// -/// ## Notes: -/// -/// Despite being titled `Production...`, this code is not ready for production. The name -/// demonstrates an intention, not a promise. pub struct ProductionBeaconNode<E: EthSpec>(ProductionClient<E>); impl<E: EthSpec> ProductionBeaconNode<E> { diff --git a/beacon_node/store/src/config.rs b/beacon_node/store/src/config.rs index 0aa00e659b..29705283fa 100644 --- a/beacon_node/store/src/config.rs +++ b/beacon_node/store/src/config.rs @@ -8,7 +8,7 @@ use std::num::NonZeroUsize; use strum::{Display, EnumString, VariantNames}; use superstruct::superstruct; use types::EthSpec; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; use zstd::{Decoder, Encoder}; #[cfg(all(feature = "redb", not(feature = "leveldb")))] diff --git a/beacon_node/store/src/consensus_context.rs b/beacon_node/store/src/consensus_context.rs deleted file mode 100644 index 281106d9aa..0000000000 --- a/beacon_node/store/src/consensus_context.rs +++ /dev/null @@ -1,65 +0,0 @@ -use ssz_derive::{Decode, Encode}; -use state_processing::ConsensusContext; -use std::collections::HashMap; -use types::{EthSpec, Hash256, IndexedAttestation, Slot}; - -/// The consensus context is stored on disk as part of the data availability overflow cache. -/// -/// We use this separate struct to keep the on-disk format stable in the presence of changes to the -/// in-memory `ConsensusContext`. You MUST NOT change the fields of this struct without -/// superstructing it and implementing a schema migration. -#[derive(Debug, PartialEq, Clone, Encode, Decode)] -pub struct OnDiskConsensusContext<E: EthSpec> { - /// Slot to act as an identifier/safeguard - slot: Slot, - /// Proposer index of the block at `slot`. - proposer_index: Option<u64>, - /// Block root of the block at `slot`. - current_block_root: Option<Hash256>, - /// We keep the indexed attestations in the *in-memory* version of this struct so that we don't - /// need to regenerate them if roundtripping via this type *without* going to disk. - /// - /// They are not part of the on-disk format. - #[ssz(skip_serializing, skip_deserializing)] - indexed_attestations: HashMap<Hash256, IndexedAttestation<E>>, -} - -impl<E: EthSpec> OnDiskConsensusContext<E> { - pub fn from_consensus_context(ctxt: ConsensusContext<E>) -> Self { - // Match exhaustively on fields here so we are forced to *consider* updating the on-disk - // format when the `ConsensusContext` fields change. - let ConsensusContext { - slot, - previous_epoch: _, - current_epoch: _, - proposer_index, - current_block_root, - indexed_attestations, - } = ctxt; - OnDiskConsensusContext { - slot, - proposer_index, - current_block_root, - indexed_attestations, - } - } - - pub fn into_consensus_context(self) -> ConsensusContext<E> { - let OnDiskConsensusContext { - slot, - proposer_index, - current_block_root, - indexed_attestations, - } = self; - - let mut ctxt = ConsensusContext::new(slot); - - if let Some(proposer_index) = proposer_index { - ctxt = ctxt.set_proposer_index(proposer_index); - } - if let Some(block_root) = current_block_root { - ctxt = ctxt.set_current_block_root(block_root); - } - ctxt.set_indexed_attestations(indexed_attestations) - } -} diff --git a/beacon_node/store/src/database/leveldb_impl.rs b/beacon_node/store/src/database/leveldb_impl.rs index 6b8c615631..6e01648263 100644 --- a/beacon_node/store/src/database/leveldb_impl.rs +++ b/beacon_node/store/src/database/leveldb_impl.rs @@ -186,10 +186,8 @@ impl<E: EthSpec> LevelDB<E> { ) }; - for (start_key, end_key) in [ - endpoints(DBColumn::BeaconState), - endpoints(DBColumn::BeaconStateSummary), - ] { + { + let (start_key, end_key) = endpoints(DBColumn::BeaconStateHotSummary); self.db.compact(&start_key, &end_key); } diff --git a/beacon_node/store/src/hdiff.rs b/beacon_node/store/src/hdiff.rs index 323c87a914..85ac56454c 100644 --- a/beacon_node/store/src/hdiff.rs +++ b/beacon_node/store/src/hdiff.rs @@ -11,7 +11,7 @@ use std::ops::RangeInclusive; use std::str::FromStr; use std::sync::LazyLock; use superstruct::superstruct; -use types::historical_summary::HistoricalSummary; +use types::state::HistoricalSummary; use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot, Validator}; static EMPTY_PUBKEY: LazyLock<PublicKeyBytes> = LazyLock::new(PublicKeyBytes::empty); @@ -654,6 +654,12 @@ impl HierarchyModuli { /// layer 2 diff will point to the start snapshot instead of the layer 1 diff at /// 2998272. pub fn storage_strategy(&self, slot: Slot, start_slot: Slot) -> Result<StorageStrategy, Error> { + // Initially had the idea of using different storage strategies for full and pending states, + // but it was very complex. However without this concept we end up storing two diffs/two + // snapshots at full slots. The complexity of managing skipped slots was the main impetus + // for reverting the payload-status sensitive design: a Full skipped slot has no same-slot + // Pending state to replay from, so has to be handled differently from Full non-skipped + // slots. match slot.cmp(&start_slot) { Ordering::Less => return Err(Error::LessThanStart(slot, start_slot)), Ordering::Equal => return Ok(StorageStrategy::Snapshot), diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index c413719174..e9b9de76e6 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -38,9 +38,9 @@ use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::time::Duration; -use tracing::{debug, error, info, instrument, warn}; +use tracing::{debug, debug_span, error, info, instrument, warn}; use typenum::Unsigned; -use types::data_column_sidecar::{ColumnIndex, DataColumnSidecar, DataColumnSidecarList}; +use types::data::{ColumnIndex, DataColumnSidecar, DataColumnSidecarList}; use types::*; use zstd::{Decoder, Encoder}; @@ -114,7 +114,7 @@ impl<E: EthSpec> BlockCache<E> { pub fn put_data_column(&mut self, block_root: Hash256, data_column: Arc<DataColumnSidecar<E>>) { self.data_column_cache .get_or_insert_mut(block_root, Default::default) - .insert(data_column.index, data_column); + .insert(*data_column.index(), data_column); } pub fn put_data_column_custody_info( &mut self, @@ -186,6 +186,7 @@ pub enum HotColdDBError { MissingHotHDiff(Hash256), MissingHDiff(Slot), MissingExecutionPayload(Hash256), + MissingExecutionPayloadEnvelope(Hash256), MissingFullBlockExecutionPayloadPruned(Hash256, Slot), MissingAnchorInfo, MissingFrozenBlockSlot(Hash256), @@ -721,14 +722,6 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> }) } - /// Fetch a block from the store, ignoring which fork variant it *should* be for. - pub fn get_block_any_variant<Payload: AbstractExecPayload<E>>( - &self, - block_root: &Hash256, - ) -> Result<Option<SignedBeaconBlock<E, Payload>>, Error> { - self.get_block_with(block_root, SignedBeaconBlock::any_from_ssz_bytes) - } - /// Fetch a block from the store using a custom decode function. /// /// This is useful for e.g. ignoring the slot-indicated fork to forcefully load a block as if it @@ -745,6 +738,32 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> .map_err(|e| e.into()) } + pub fn get_payload_envelope( + &self, + block_root: &Hash256, + ) -> Result<Option<SignedExecutionPayloadEnvelope<E>>, Error> { + let key = block_root.as_slice(); + + match self + .hot_db + .get_bytes(SignedExecutionPayloadEnvelope::<E>::db_column(), key)? + { + Some(bytes) => { + let envelope = SignedExecutionPayloadEnvelope::from_ssz_bytes(&bytes)?; + Ok(Some(envelope)) + } + None => Ok(None), + } + } + + /// Check if the payload envelope for a block exists on disk. + pub fn payload_envelope_exists(&self, block_root: &Hash256) -> Result<bool, Error> { + self.hot_db.key_exists( + SignedExecutionPayloadEnvelope::<E>::db_column(), + block_root.as_slice(), + ) + } + /// Load the execution payload for a block from disk. /// This method deserializes with the proper fork. pub fn get_execution_payload( @@ -969,7 +988,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> ) { ops.push(KeyValueStoreOp::PutKeyValue( DBColumn::BeaconDataColumn, - get_data_column_key(block_root, &data_column.index), + get_data_column_key(block_root, data_column.index()), data_column.as_ssz_bytes(), )); } @@ -1002,7 +1021,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> for data_column in data_columns { self.blobs_db.put_bytes( DBColumn::BeaconDataColumn, - &get_data_column_key(block_root, &data_column.index), + &get_data_column_key(block_root, data_column.index()), &data_column.as_ssz_bytes(), )?; self.block_cache @@ -1021,12 +1040,39 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> for data_column in data_columns { ops.push(KeyValueStoreOp::PutKeyValue( DBColumn::BeaconDataColumn, - get_data_column_key(block_root, &data_column.index), + get_data_column_key(block_root, data_column.index()), data_column.as_ssz_bytes(), )); } } + // TODO(gloas) we should store the execution payload separately like we do for blocks. + /// Prepare a signed execution payload envelope for storage in the database. + pub fn payload_envelope_as_kv_store_ops( + &self, + key: &Hash256, + payload: &SignedExecutionPayloadEnvelope<E>, + ops: &mut Vec<KeyValueStoreOp>, + ) { + ops.push(KeyValueStoreOp::PutKeyValue( + SignedExecutionPayloadEnvelope::<E>::db_column(), + key.as_slice().into(), + payload.as_ssz_bytes(), + )); + } + + pub fn put_payload_envelope( + &self, + block_root: &Hash256, + payload_envelope: &SignedExecutionPayloadEnvelope<E>, + ) -> Result<(), Error> { + self.hot_db.put_bytes( + SignedExecutionPayloadEnvelope::<E>::db_column(), + block_root.as_slice(), + &payload_envelope.as_ssz_bytes(), + ) + } + /// Store a state in the store. pub fn put_state(&self, state_root: &Hash256, state: &BeaconState<E>) -> Result<(), Error> { let mut ops: Vec<KeyValueStoreOp> = Vec::new(); @@ -1283,6 +1329,14 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> ); } + StoreOp::PutPayloadEnvelope(block_root, payload_envelope) => { + self.payload_envelope_as_kv_store_ops( + &block_root, + &payload_envelope, + &mut key_value_batch, + ); + } + StoreOp::PutStateSummary(state_root, summary) => { key_value_batch.push(summary.as_kv_store_op(state_root)); } @@ -1301,7 +1355,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> )); } - StoreOp::DeleteDataColumns(block_root, column_indices) => { + StoreOp::DeleteDataColumns(block_root, column_indices, _) => { for index in column_indices { let key = get_data_column_key(&block_root, &index); key_value_batch @@ -1309,6 +1363,13 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> } } + StoreOp::DeletePayloadEnvelope(block_root) => { + key_value_batch.push(KeyValueStoreOp::DeleteKey( + SignedExecutionPayloadEnvelope::<E>::db_column(), + block_root.as_slice().to_vec(), + )) + } + StoreOp::DeleteState(state_root, slot) => { // Delete the hot state summary. key_value_batch.push(KeyValueStoreOp::DeleteKey( @@ -1319,6 +1380,8 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> // NOTE: `hot_storage_strategy` can error if there are states in the database // prior to the `anchor_slot`. This can happen if checkpoint sync has been // botched and left some states in the database prior to completing. + // Use `Pending` status here because snapshots and diffs are only stored for + // `Pending` states. if let Some(slot) = slot && let Ok(strategy) = self.hot_storage_strategy(slot) { @@ -1415,10 +1478,10 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> } true } - StoreOp::DeleteDataColumns(block_root, indices) => { + StoreOp::DeleteDataColumns(block_root, indices, fork_name) => { match indices .iter() - .map(|index| self.get_data_column(block_root, index)) + .map(|index| self.get_data_column(block_root, index, *fork_name)) .collect::<Result<Vec<_>, _>>() { Ok(data_column_sidecar_list_opt) => { @@ -1450,14 +1513,24 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> let blob_cache_ops = blobs_ops.clone(); // Try to execute blobs store ops. - self.blobs_db - .do_atomically(self.convert_to_kv_batch(blobs_ops)?)?; + let kv_blob_ops = self.convert_to_kv_batch(blobs_ops)?; + { + let _span = debug_span!("write_blobs_db").entered(); + self.blobs_db.do_atomically(kv_blob_ops)?; + } let hot_db_cache_ops = hot_db_ops.clone(); // Try to execute hot db store ops. - let tx_res = match self.convert_to_kv_batch(hot_db_ops) { - Ok(kv_store_ops) => self.hot_db.do_atomically(kv_store_ops), - Err(e) => Err(e), + let tx_res = { + let _convert_span = debug_span!("convert_hot_db_ops").entered(); + match self.convert_to_kv_batch(hot_db_ops) { + Ok(kv_store_ops) => { + drop(_convert_span); + let _span = debug_span!("write_hot_db").entered(); + self.hot_db.do_atomically(kv_store_ops) + } + Err(e) => Err(e), + } }; // Rollback on failure if let Err(e) = tx_res { @@ -1471,14 +1544,24 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> let reverse_op = match op { StoreOp::PutBlobs(block_root, _) => StoreOp::DeleteBlobs(*block_root), StoreOp::PutDataColumns(block_root, data_columns) => { - let indices = data_columns.iter().map(|c| c.index).collect(); - StoreOp::DeleteDataColumns(*block_root, indices) + let indices = data_columns.iter().map(|c| *c.index()).collect(); + + match data_columns.first() { + Some(column) => { + let slot = column.slot(); + let fork_name = self.spec.fork_name_at_slot::<E>(slot); + StoreOp::DeleteDataColumns(*block_root, indices, fork_name) + } + // It shouldn't be possible to reach this case. We're reverting + // a `PutDataColumn` operation that attempted to write columns to the store. + None => return Err(HotColdDBError::Rollback.into()), + } } StoreOp::DeleteBlobs(_) => match blobs_to_delete.pop() { Some((block_root, blobs)) => StoreOp::PutBlobs(block_root, blobs), None => return Err(HotColdDBError::Rollback.into()), }, - StoreOp::DeleteDataColumns(_, _) => match data_columns_to_delete.pop() { + StoreOp::DeleteDataColumns(_, _, _) => match data_columns_to_delete.pop() { Some((block_root, data_columns)) => { StoreOp::PutDataColumns(block_root, data_columns) } @@ -1518,6 +1601,8 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> StoreOp::PutDataColumns(_, _) => (), + StoreOp::PutPayloadEnvelope(_, _) => (), + StoreOp::PutState(_, _) => (), StoreOp::PutStateSummary(_, _) => (), @@ -1526,11 +1611,13 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> guard.delete_block(&block_root); } + StoreOp::DeletePayloadEnvelope(_) => (), + StoreOp::DeleteState(_, _) => (), StoreOp::DeleteBlobs(_) => (), - StoreOp::DeleteDataColumns(_, _) => (), + StoreOp::DeleteDataColumns(_, _, _) => (), StoreOp::DeleteExecutionPayload(_) => (), @@ -1864,6 +1951,11 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> .. }) = self.load_hot_state_summary(state_root)? { + debug!( + %slot, + ?state_root, + "Loading hot state" + ); let mut state = match self.hot_storage_strategy(slot)? { strat @ StorageStrategy::Snapshot | strat @ StorageStrategy::DiffFrom(_) => { let buffer_timer = metrics::start_timer_vec( @@ -1968,6 +2060,12 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> Ok(()) }; + debug!( + %slot, + blocks = ?blocks.iter().map(|block| block.slot()).collect::<Vec<_>>(), + "Replaying blocks" + ); + self.replay_blocks( base_state, blocks, @@ -2274,7 +2372,8 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> return Ok(base_state); } - let blocks = self.load_cold_blocks(base_state.slot() + 1, slot)?; + let base_slot = base_state.slot(); + let blocks = self.load_cold_blocks(base_slot + 1, slot)?; // Include state root for base state as it is required by block processing to not // have to hash the state. @@ -2398,7 +2497,8 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> })? } - /// Load the blocks between `start_slot` and `end_slot` by backtracking from `end_block_hash`. + /// Load the blocks between `start_slot` and `end_slot` by backtracking from + /// `end_block_root`. /// /// Blocks are returned in slot-ascending order, suitable for replaying on a state with slot /// equal to `start_slot`, to reach a state with slot equal to `end_slot`. @@ -2406,10 +2506,10 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> &self, start_slot: Slot, end_slot: Slot, - end_block_hash: Hash256, - ) -> Result<Vec<SignedBeaconBlock<E, BlindedPayload<E>>>, Error> { + end_block_root: Hash256, + ) -> Result<Vec<SignedBlindedBeaconBlock<E>>, Error> { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_HOT_BLOCKS_TIME); - let mut blocks = ParentRootBlockIterator::new(self, end_block_hash) + let mut blocks = ParentRootBlockIterator::new(self, end_block_root) .map(|result| result.map(|(_, block)| block)) // Include the block at the end slot (if any), it needs to be // replayed in order to construct the canonical state at `end_slot`. @@ -2446,7 +2546,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> pub fn replay_blocks( &self, state: BeaconState<E>, - blocks: Vec<SignedBeaconBlock<E, BlindedPayload<E>>>, + blocks: Vec<SignedBlindedBeaconBlock<E>>, target_slot: Slot, state_root_iter: Option<impl Iterator<Item = Result<(Hash256, Slot), Error>>>, pre_slot_hook: Option<PreSlotHook<E, Error>>, @@ -2506,12 +2606,16 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> pub fn get_data_columns( &self, block_root: &Hash256, + fork_name: ForkName, ) -> Result<Option<DataColumnSidecarList<E>>, Error> { let column_indices = self.get_data_column_keys(*block_root)?; let columns: DataColumnSidecarList<E> = column_indices .into_iter() - .filter_map(|col_index| self.get_data_column(block_root, &col_index).transpose()) + .filter_map(|col_index| { + self.get_data_column(block_root, &col_index, fork_name) + .transpose() + }) .collect::<Result<_, _>>()?; Ok((!columns.is_empty()).then_some(columns)) @@ -2585,6 +2689,7 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> &self, block_root: &Hash256, column_index: &ColumnIndex, + fork_name: ForkName, ) -> Result<Option<Arc<DataColumnSidecar<E>>>, Error> { // Check the cache. if let Some(data_column) = self @@ -2601,7 +2706,10 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> &get_data_column_key(block_root, column_index), )? { Some(ref data_column_bytes) => { - let data_column = Arc::new(DataColumnSidecar::from_ssz_bytes(data_column_bytes)?); + let data_column = Arc::new(DataColumnSidecar::from_ssz_bytes_for_fork( + data_column_bytes, + fork_name, + )?); self.block_cache.as_ref().inspect(|cache| { cache .lock() @@ -2945,12 +3053,10 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> Some(mut split) => { debug!(?split, "Loaded split partial"); // Load the hot state summary to get the block root. - let latest_block_root = self - .load_block_root_from_summary_any_version(&split.state_root) - .ok_or(HotColdDBError::MissingSplitState( - split.state_root, - split.slot, - ))?; + let latest_block_root = + self.load_block_root_from_summary(&split.state_root).ok_or( + HotColdDBError::MissingSplitState(split.state_root, split.slot), + )?; split.block_root = latest_block_root; Ok(Some(split)) } @@ -2981,29 +3087,11 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> .map_err(|e| Error::LoadHotStateSummary(*state_root, e.into())) } - /// Load a hot state's summary in V22 format, given its root. - pub fn load_hot_state_summary_v22( - &self, - state_root: &Hash256, - ) -> Result<Option<HotStateSummaryV22>, Error> { - self.hot_db - .get(state_root) - .map_err(|e| Error::LoadHotStateSummary(*state_root, e.into())) - } - - /// Load the latest block root for a hot state summary either in modern form, or V22 form. - /// - /// This function is required to open a V22 database for migration to V24, or vice versa. - pub fn load_block_root_from_summary_any_version( - &self, - state_root: &Hash256, - ) -> Option<Hash256> { + /// Load the latest block root for a hot state summary. + pub fn load_block_root_from_summary(&self, state_root: &Hash256) -> Option<Hash256> { if let Ok(Some(summary)) = self.load_hot_state_summary(state_root) { return Some(summary.latest_block_root); } - if let Ok(Some(summary)) = self.load_hot_state_summary_v22(state_root) { - return Some(summary.latest_block_root); - } None } @@ -3495,6 +3583,7 @@ pub fn migrate_database<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>>( ) -> Result<SplitChange, Error> { debug!( slot = %finalized_state.slot(), + state_root = ?finalized_state_root, "Freezer migration started" ); @@ -3918,7 +4007,7 @@ impl HotStateSummary { if slot == state.slot() { Ok::<_, Error>(state_root) } else { - Ok(get_ancestor_state_root(store, state, slot).map_err(|e| { + Ok::<_, Error>(get_ancestor_state_root(store, state, slot).map_err(|e| { Error::StateSummaryIteratorError { error: e, from_state_root: state_root, @@ -3952,30 +4041,6 @@ impl HotStateSummary { } } -/// Legacy hot state summary used in schema V22 and before. -/// -/// This can be deleted when we remove V22 support. -#[derive(Debug, Clone, Copy, Encode, Decode)] -pub struct HotStateSummaryV22 { - pub slot: Slot, - pub latest_block_root: Hash256, - pub epoch_boundary_state_root: Hash256, -} - -impl StoreItem for HotStateSummaryV22 { - fn db_column() -> DBColumn { - DBColumn::BeaconStateSummary - } - - fn as_store_bytes(&self) -> Vec<u8> { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result<Self, Error> { - Ok(Self::from_ssz_bytes(bytes)?) - } -} - /// Struct for summarising a state in the freezer database. #[derive(Debug, Clone, Copy, Default, Encode, Decode)] pub(crate) struct ColdStateSummary { diff --git a/beacon_node/store/src/impls.rs b/beacon_node/store/src/impls.rs index 691c79ace7..a2b2f3b2d6 100644 --- a/beacon_node/store/src/impls.rs +++ b/beacon_node/store/src/impls.rs @@ -1 +1,2 @@ pub mod execution_payload; +mod signed_execution_payload_envelope; diff --git a/beacon_node/store/src/impls/signed_execution_payload_envelope.rs b/beacon_node/store/src/impls/signed_execution_payload_envelope.rs new file mode 100644 index 0000000000..3faab4b7d5 --- /dev/null +++ b/beacon_node/store/src/impls/signed_execution_payload_envelope.rs @@ -0,0 +1,18 @@ +use ssz::{Decode, Encode}; +use types::{EthSpec, SignedExecutionPayloadEnvelope}; + +use crate::{DBColumn, Error, StoreItem}; + +impl<E: EthSpec> StoreItem for SignedExecutionPayloadEnvelope<E> { + fn db_column() -> DBColumn { + DBColumn::PayloadEnvelope + } + + fn as_store_bytes(&self) -> Vec<u8> { + self.as_ssz_bytes() + } + + fn from_store_bytes(bytes: &[u8]) -> Result<Self, Error> { + Ok(Self::from_ssz_bytes(bytes)?) + } +} diff --git a/beacon_node/store/src/invariants.rs b/beacon_node/store/src/invariants.rs new file mode 100644 index 0000000000..d251fb8800 --- /dev/null +++ b/beacon_node/store/src/invariants.rs @@ -0,0 +1,796 @@ +//! Database invariant checks for the hot and cold databases. +//! +//! These checks verify the consistency of data stored in the database. They are designed to be +//! called from the HTTP API and from tests to detect data corruption or bugs in the store logic. +//! +//! See the `check_invariants` and `check_database_invariants` methods for the full list. + +use crate::hdiff::StorageStrategy; +use crate::hot_cold_store::{ColdStateSummary, HotStateSummary}; +use crate::{DBColumn, Error, ItemStore}; +use crate::{HotColdDB, Split}; +use serde::Serialize; +use ssz::Decode; +use std::cmp; +use std::collections::HashSet; +use types::*; + +/// Result of running invariant checks on the database. +#[derive(Debug, Clone, Serialize)] +pub struct InvariantCheckResult { + /// List of invariant violations found. + pub violations: Vec<InvariantViolation>, +} + +impl InvariantCheckResult { + pub fn new() -> Self { + Self { + violations: Vec::new(), + } + } + + pub fn is_ok(&self) -> bool { + self.violations.is_empty() + } + + pub fn add_violation(&mut self, violation: InvariantViolation) { + self.violations.push(violation); + } + + pub fn merge(&mut self, other: InvariantCheckResult) { + self.violations.extend(other.violations); + } +} + +impl Default for InvariantCheckResult { + fn default() -> Self { + Self::new() + } +} + +/// Context data from the beacon chain needed for invariant checks. +/// +/// This allows all invariant checks to live in the store crate while still checking +/// invariants that depend on fork choice, state cache, and custody context. +pub struct InvariantContext { + /// Block roots tracked by fork choice (invariant 1). + pub fork_choice_blocks: Vec<(Hash256, Slot)>, + /// State roots held in the in-memory state cache (invariant 8). + pub state_cache_roots: Vec<Hash256>, + /// Custody columns for the current epoch (invariant 7). + pub custody_columns: Vec<ColumnIndex>, + /// Compressed pubkey bytes from the in-memory validator pubkey cache, indexed by validator index + /// (invariant 9). + pub pubkey_cache_pubkeys: Vec<Vec<u8>>, +} + +/// A single invariant violation. +#[derive(Debug, Clone, Serialize)] +pub enum InvariantViolation { + /// Invariant 1: fork choice block consistency. + /// + /// ```text + /// block in fork_choice && descends_from_finalized -> block in hot_db + /// ``` + ForkChoiceBlockMissing { block_root: Hash256, slot: Slot }, + /// Invariant 2: block and state consistency. + /// + /// ```text + /// block in hot_db && block.slot >= split.slot + /// -> state_summary for block.state_root() in hot_db + /// ``` + HotBlockMissingStateSummary { + block_root: Hash256, + slot: Slot, + state_root: Hash256, + }, + /// Invariant 3: state summary diff consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateMissingSnapshot { state_root: Hash256, slot: Slot }, + /// Invariant 3: state summary diff consistency (missing diff). + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateMissingDiff { state_root: Hash256, slot: Slot }, + /// Invariant 3: DiffFrom/ReplayFrom base slot must reference an existing summary. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateBaseSummaryMissing { + slot: Slot, + base_state_root: Hash256, + }, + /// Invariant 4: state summary chain consistency. + /// + /// ```text + /// state_summary in hot_db && state_summary.slot > split.slot + /// -> state_summary for previous_state_root in hot_db + /// ``` + HotStateMissingPreviousSummary { + slot: Slot, + previous_state_root: Hash256, + }, + /// Invariant 5: block and execution payload consistency. + /// + /// ```text + /// block in hot_db && !prune_payloads -> payload for block.root in hot_db + /// ``` + ExecutionPayloadMissing { block_root: Hash256, slot: Slot }, + /// Invariant 6: block and blobs consistency. + /// + /// ```text + /// block in hot_db && num_blob_commitments > 0 + /// -> blob_list for block.root in hot_db + /// ``` + BlobSidecarMissing { block_root: Hash256, slot: Slot }, + /// Invariant 7: block and data columns consistency. + /// + /// ```text + /// block in hot_db && num_blob_commitments > 0 + /// && block.slot >= earliest_available_slot + /// && data_column_idx in custody_columns + /// -> (block_root, data_column_idx) in hot_db + /// ``` + DataColumnMissing { + block_root: Hash256, + slot: Slot, + column_index: ColumnIndex, + }, + /// Invariant 8: state cache and disk consistency. + /// + /// ```text + /// state in state_cache -> state_summary in hot_db + /// ``` + StateCacheMissingSummary { state_root: Hash256 }, + /// Invariant 9: pubkey cache consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> all validator pubkeys from state.validators are in the hot_db + /// ``` + PubkeyCacheMissing { validator_index: usize }, + /// Invariant 9b: pubkey cache value mismatch. + /// + /// ```text + /// pubkey_cache[i] == hot_db(PubkeyCache)[i] + /// ``` + PubkeyCacheMismatch { validator_index: usize }, + /// Invariant 10: block root indices mapping. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + ColdBlockRootMissing { + slot: Slot, + oldest_block_slot: Slot, + split_slot: Slot, + }, + /// Invariant 10: block root index references a block that must exist. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + ColdBlockRootOrphan { slot: Slot, block_root: Hash256 }, + /// Invariant 11: state root indices mapping. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootMissing { + slot: Slot, + state_lower_limit: Slot, + state_upper_limit: Slot, + split_slot: Slot, + }, + /// Invariant 11: state root index must have a cold state summary. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootMissingSummary { slot: Slot, state_root: Hash256 }, + /// Invariant 11: cold state summary slot must match index slot. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootSlotMismatch { + slot: Slot, + state_root: Hash256, + summary_slot: Slot, + }, + /// Invariant 12: cold state diff consistency. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateMissingSnapshot { state_root: Hash256, slot: Slot }, + /// Invariant 12: cold state diff consistency (missing diff). + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateMissingDiff { state_root: Hash256, slot: Slot }, + /// Invariant 12: DiffFrom/ReplayFrom base slot must reference an existing summary. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateBaseSummaryMissing { slot: Slot, base_slot: Slot }, +} + +impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> HotColdDB<E, Hot, Cold> { + /// Run all database invariant checks. + /// + /// The `ctx` parameter provides data from the beacon chain layer (fork choice, state cache, + /// custody columns, pubkey cache) so that all invariant checks can live in this single file. + pub fn check_invariants(&self, ctx: &InvariantContext) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + let split = self.get_split_info(); + + result.merge(self.check_fork_choice_block_consistency(ctx)?); + result.merge(self.check_hot_block_invariants(&split, ctx)?); + result.merge(self.check_hot_state_summary_diff_consistency()?); + result.merge(self.check_hot_state_summary_chain_consistency(&split)?); + result.merge(self.check_state_cache_consistency(ctx)?); + result.merge(self.check_cold_block_root_indices(&split)?); + result.merge(self.check_cold_state_root_indices(&split)?); + result.merge(self.check_cold_state_diff_consistency()?); + result.merge(self.check_pubkey_cache_consistency(ctx)?); + + Ok(result) + } + + /// Invariant 1 (Hot DB): Fork choice block consistency. + /// + /// ```text + /// block in fork_choice && descends_from_finalized -> block in hot_db + /// ``` + /// + /// Every canonical fork choice block (descending from finalized) must exist in the hot + /// database. Pruned non-canonical fork blocks may linger in the proto-array and are + /// excluded from this check. + fn check_fork_choice_block_consistency( + &self, + ctx: &InvariantContext, + ) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + for &(block_root, slot) in &ctx.fork_choice_blocks { + let exists = self + .hot_db + .key_exists(DBColumn::BeaconBlock, block_root.as_slice())?; + if !exists { + result + .add_violation(InvariantViolation::ForkChoiceBlockMissing { block_root, slot }); + } + } + + Ok(result) + } + + /// Invariants 2, 5, 6, 7 (Hot DB): Block-related consistency checks. + /// + /// Iterates hot DB blocks once and checks: + /// - Invariant 2: block-state summary consistency + /// - Invariant 5: execution payload consistency (when prune_payloads=false) + /// - Invariant 6: blob sidecar consistency (Deneb to Fulu) + /// - Invariant 7: data column consistency (post-Fulu, when custody_columns provided) + fn check_hot_block_invariants( + &self, + split: &Split, + ctx: &InvariantContext, + ) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + let check_payloads = !self.get_config().prune_payloads; + let bellatrix_fork_slot = self + .spec + .bellatrix_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let deneb_fork_slot = self + .spec + .deneb_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let fulu_fork_slot = self + .spec + .fulu_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let gloas_fork_slot = self + .spec + .gloas_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let oldest_blob_slot = self.get_blob_info().oldest_blob_slot; + let oldest_data_column_slot = self.get_data_column_info().oldest_data_column_slot; + + for res in self.hot_db.iter_column::<Hash256>(DBColumn::BeaconBlock) { + let (block_root, block_bytes) = res?; + let block = SignedBlindedBeaconBlock::<E>::from_ssz_bytes(&block_bytes, &self.spec)?; + let slot = block.slot(); + + // Invariant 2: block-state consistency. + if slot >= split.slot { + let state_root = block.state_root(); + let has_summary = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, state_root.as_slice())?; + if !has_summary { + result.add_violation(InvariantViolation::HotBlockMissingStateSummary { + block_root, + slot, + state_root, + }); + } + } + + // Invariant 5: execution payload consistency. + if check_payloads + && let Some(bellatrix_slot) = bellatrix_fork_slot + && slot >= bellatrix_slot + { + if let Some(gloas_slot) = gloas_fork_slot + && slot >= gloas_slot + { + // For Gloas there is never a true payload stored at slot 0. + // TODO(gloas): still need to account for non-canonical payloads once pruning + // is implemented. + if slot != 0 && !self.payload_envelope_exists(&block_root)? { + result.add_violation(InvariantViolation::ExecutionPayloadMissing { + block_root, + slot, + }); + } + } else if !self.execution_payload_exists(&block_root)? { + result.add_violation(InvariantViolation::ExecutionPayloadMissing { + block_root, + slot, + }); + } + } + + // Invariant 6: blob sidecar consistency. + // Only check blocks that actually have blob KZG commitments — blocks with 0 + // commitments legitimately have no blob sidecars stored. + if let Some(deneb_slot) = deneb_fork_slot + && let Some(oldest_blob) = oldest_blob_slot + && slot >= deneb_slot + && slot >= oldest_blob + && fulu_fork_slot.is_none_or(|fulu_slot| slot < fulu_slot) + && block.num_expected_blobs() > 0 + { + let has_blob = self + .blobs_db + .key_exists(DBColumn::BeaconBlob, block_root.as_slice())?; + if !has_blob { + result + .add_violation(InvariantViolation::BlobSidecarMissing { block_root, slot }); + } + } + + // Invariant 7: data column consistency. + // Only check blocks that actually have blob KZG commitments. + // TODO(gloas): reconsider this invariant — non-canonical payloads won't have + // their data column sidecars stored. + if !ctx.custody_columns.is_empty() + && let Some(fulu_slot) = fulu_fork_slot + && let Some(oldest_dc) = oldest_data_column_slot + && slot >= fulu_slot + && slot >= oldest_dc + && block.num_expected_blobs() > 0 + { + let stored_columns = self.get_data_column_keys(block_root)?; + for col_idx in &ctx.custody_columns { + if !stored_columns.contains(col_idx) { + result.add_violation(InvariantViolation::DataColumnMissing { + block_root, + slot, + column_index: *col_idx, + }); + } + } + } + } + + Ok(result) + } + + /// Invariant 3 (Hot DB): State summary diff/snapshot consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db per HDiff hierarchy rules + /// ``` + /// + /// Each hot state summary should have the correct storage artifact (snapshot, diff, or + /// nothing) according to the HDiff hierarchy configuration. The hierarchy uses the + /// anchor_slot as its start point for the hot DB. + fn check_hot_state_summary_diff_consistency(&self) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + let anchor_slot = self.get_anchor_info().anchor_slot; + + // Collect all summary slots and their strategies in a first pass. + let mut known_state_roots = HashSet::new(); + let mut base_state_refs: Vec<(Slot, Hash256)> = Vec::new(); + + for res in self + .hot_db + .iter_column::<Hash256>(DBColumn::BeaconStateHotSummary) + { + let (state_root, value) = res?; + let summary = HotStateSummary::from_ssz_bytes(&value)?; + + known_state_roots.insert(state_root); + + match self.hierarchy.storage_strategy(summary.slot, anchor_slot)? { + StorageStrategy::Snapshot => { + let has_snapshot = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSnapshot, state_root.as_slice())?; + if !has_snapshot { + result.add_violation(InvariantViolation::HotStateMissingSnapshot { + state_root, + slot: summary.slot, + }); + } + } + StorageStrategy::DiffFrom(base_slot) => { + let has_diff = self + .hot_db + .key_exists(DBColumn::BeaconStateHotDiff, state_root.as_slice())?; + if !has_diff { + result.add_violation(InvariantViolation::HotStateMissingDiff { + state_root, + slot: summary.slot, + }); + } + if let Ok(base_root) = summary.diff_base_state.get_root(base_slot) { + base_state_refs.push((summary.slot, base_root)); + } + } + StorageStrategy::ReplayFrom(base_slot) => { + if let Ok(base_root) = summary.diff_base_state.get_root(base_slot) { + base_state_refs.push((summary.slot, base_root)); + } + } + } + } + + // Verify that all diff base state roots reference existing summaries. + for (slot, base_state_root) in base_state_refs { + if !known_state_roots.contains(&base_state_root) { + result.add_violation(InvariantViolation::HotStateBaseSummaryMissing { + slot, + base_state_root, + }); + } + } + + Ok(result) + } + + /// Invariant 4 (Hot DB): State summary chain consistency. + /// + /// ```text + /// state_summary in hot_db && state_summary.slot > split.slot + /// -> state_summary for previous_state_root in hot_db + /// ``` + /// + /// The chain of `previous_state_root` links must be continuous back to the split state. + /// The split state itself is the boundary and does not need a predecessor in the hot DB. + fn check_hot_state_summary_chain_consistency( + &self, + split: &Split, + ) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + for res in self + .hot_db + .iter_column::<Hash256>(DBColumn::BeaconStateHotSummary) + { + let (_state_root, value) = res?; + let summary = HotStateSummary::from_ssz_bytes(&value)?; + + if summary.slot > split.slot { + let prev_root = summary.previous_state_root; + let has_prev = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, prev_root.as_slice())?; + if !has_prev { + result.add_violation(InvariantViolation::HotStateMissingPreviousSummary { + slot: summary.slot, + previous_state_root: prev_root, + }); + } + } + } + + Ok(result) + } + + /// Invariant 8 (Hot DB): State cache and disk consistency. + /// + /// ```text + /// state in state_cache -> state_summary in hot_db + /// ``` + /// + /// Every state held in the in-memory state cache (including the finalized state) should + /// have a corresponding hot state summary on disk. + fn check_state_cache_consistency( + &self, + ctx: &InvariantContext, + ) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + for &state_root in &ctx.state_cache_roots { + let has_summary = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, state_root.as_slice())?; + if !has_summary { + result.add_violation(InvariantViolation::StateCacheMissingSummary { state_root }); + } + } + + Ok(result) + } + + /// Invariant 10 (Cold DB): Block root indices. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + /// + /// Every slot in the cold range (from `oldest_block_slot` to `split.slot`) should have a + /// block root index entry, and the referenced block should exist in the hot DB. Note that + /// skip slots store the most recent non-skipped block's root, so `block.slot()` may differ + /// from the index slot. + fn check_cold_block_root_indices(&self, split: &Split) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + let anchor_info = self.get_anchor_info(); + + if anchor_info.oldest_block_slot >= split.slot { + return Ok(result); + } + + for slot_val in anchor_info.oldest_block_slot.as_u64()..split.slot.as_u64() { + let slot = Slot::new(slot_val); + + let slot_bytes = slot_val.to_be_bytes(); + let block_root_bytes = self + .cold_db + .get_bytes(DBColumn::BeaconBlockRoots, &slot_bytes)?; + + let Some(root_bytes) = block_root_bytes else { + result.add_violation(InvariantViolation::ColdBlockRootMissing { + slot, + oldest_block_slot: anchor_info.oldest_block_slot, + split_slot: split.slot, + }); + continue; + }; + + if root_bytes.len() != 32 { + return Err(Error::InvalidKey(format!( + "cold block root at slot {slot} has invalid length {}", + root_bytes.len() + ))); + } + + let block_root = Hash256::from_slice(&root_bytes); + let block_exists = self + .hot_db + .key_exists(DBColumn::BeaconBlock, block_root.as_slice())?; + if !block_exists { + result.add_violation(InvariantViolation::ColdBlockRootOrphan { slot, block_root }); + } + } + + Ok(result) + } + + /// Invariant 11 (Cold DB): State root indices. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + fn check_cold_state_root_indices(&self, split: &Split) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + let anchor_info = self.get_anchor_info(); + + // Expected slots are: (i <= state_lower_limit || i >= effective_upper) && i < split.slot + // where effective_upper = min(split.slot, state_upper_limit). + for slot_val in 0..split.slot.as_u64() { + let slot = Slot::new(slot_val); + + if slot <= anchor_info.state_lower_limit + || slot >= cmp::min(split.slot, anchor_info.state_upper_limit) + { + let slot_bytes = slot_val.to_be_bytes(); + let Some(root_bytes) = self + .cold_db + .get_bytes(DBColumn::BeaconStateRoots, &slot_bytes)? + else { + result.add_violation(InvariantViolation::ColdStateRootMissing { + slot, + state_lower_limit: anchor_info.state_lower_limit, + state_upper_limit: anchor_info.state_upper_limit, + split_slot: split.slot, + }); + continue; + }; + + if root_bytes.len() != 32 { + return Err(Error::InvalidKey(format!( + "cold state root at slot {slot} has invalid length {}", + root_bytes.len() + ))); + } + + let state_root = Hash256::from_slice(&root_bytes); + + match self + .cold_db + .get_bytes(DBColumn::BeaconColdStateSummary, state_root.as_slice())? + { + None => { + result.add_violation(InvariantViolation::ColdStateRootMissingSummary { + slot, + state_root, + }); + } + Some(summary_bytes) => { + let summary = ColdStateSummary::from_ssz_bytes(&summary_bytes)?; + if summary.slot != slot { + result.add_violation(InvariantViolation::ColdStateRootSlotMismatch { + slot, + state_root, + summary_slot: summary.slot, + }); + } + } + } + } + } + + Ok(result) + } + + /// Invariant 12 (Cold DB): Cold state diff/snapshot consistency. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> state diff/snapshot/nothing in cold_db per HDiff hierarchy rules + /// ``` + /// + /// Each cold state summary should have the correct storage artifact according to the + /// HDiff hierarchy. Cold states always use genesis (slot 0) as the hierarchy start since + /// they are finalized and have no anchor_slot dependency. + fn check_cold_state_diff_consistency(&self) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + let mut summary_slots = HashSet::new(); + let mut base_slot_refs = Vec::new(); + + for res in self + .cold_db + .iter_column::<Hash256>(DBColumn::BeaconColdStateSummary) + { + let (state_root, value) = res?; + let summary = ColdStateSummary::from_ssz_bytes(&value)?; + + summary_slots.insert(summary.slot); + + let slot_bytes = summary.slot.as_u64().to_be_bytes(); + + match self + .hierarchy + .storage_strategy(summary.slot, Slot::new(0))? + { + StorageStrategy::Snapshot => { + let has_snapshot = self + .cold_db + .key_exists(DBColumn::BeaconStateSnapshot, &slot_bytes)?; + if !has_snapshot { + result.add_violation(InvariantViolation::ColdStateMissingSnapshot { + state_root, + slot: summary.slot, + }); + } + } + StorageStrategy::DiffFrom(base_slot) => { + let has_diff = self + .cold_db + .key_exists(DBColumn::BeaconStateDiff, &slot_bytes)?; + if !has_diff { + result.add_violation(InvariantViolation::ColdStateMissingDiff { + state_root, + slot: summary.slot, + }); + } + base_slot_refs.push((summary.slot, base_slot)); + } + StorageStrategy::ReplayFrom(base_slot) => { + base_slot_refs.push((summary.slot, base_slot)); + } + } + } + + // Verify that all DiffFrom/ReplayFrom base slots reference existing summaries. + for (slot, base_slot) in base_slot_refs { + if !summary_slots.contains(&base_slot) { + result.add_violation(InvariantViolation::ColdStateBaseSummaryMissing { + slot, + base_slot, + }); + } + } + + Ok(result) + } + + /// Invariant 9 (Hot DB): Pubkey cache consistency. + /// + /// ```text + /// all validator pubkeys from states are in hot_db(PubkeyCache) + /// ``` + /// + /// Checks that the in-memory pubkey cache and the on-disk PubkeyCache column have the same + /// number of entries AND that each pubkey matches at every validator index. + fn check_pubkey_cache_consistency( + &self, + ctx: &InvariantContext, + ) -> Result<InvariantCheckResult, Error> { + let mut result = InvariantCheckResult::new(); + + // Read on-disk pubkeys by sequential validator index (matching how they are stored + // with Hash256::from_low_u64_be(index) as key). + // Iterate in-memory pubkeys and verify each matches on disk. + for (validator_index, in_memory_bytes) in ctx.pubkey_cache_pubkeys.iter().enumerate() { + let mut key = [0u8; 32]; + key[24..].copy_from_slice(&(validator_index as u64).to_be_bytes()); + match self.hot_db.get_bytes(DBColumn::PubkeyCache, &key)? { + Some(on_disk_bytes) if in_memory_bytes != &on_disk_bytes => { + result + .add_violation(InvariantViolation::PubkeyCacheMismatch { validator_index }); + } + None => { + result + .add_violation(InvariantViolation::PubkeyCacheMissing { validator_index }); + } + _ => {} + } + } + + Ok(result) + } +} diff --git a/beacon_node/store/src/iter.rs b/beacon_node/store/src/iter.rs index e2b666e597..0cb803d1ed 100644 --- a/beacon_node/store/src/iter.rs +++ b/beacon_node/store/src/iter.rs @@ -249,7 +249,6 @@ impl<E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> Iterator pub struct ParentRootBlockIterator<'a, E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> { store: &'a HotColdDB<E, Hot, Cold>, next_block_root: Hash256, - decode_any_variant: bool, _phantom: PhantomData<E>, } @@ -260,17 +259,6 @@ impl<'a, E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> Self { store, next_block_root: start_block_root, - decode_any_variant: false, - _phantom: PhantomData, - } - } - - /// Block iterator that is tolerant of blocks that have the wrong fork for their slot. - pub fn fork_tolerant(store: &'a HotColdDB<E, Hot, Cold>, start_block_root: Hash256) -> Self { - Self { - store, - next_block_root: start_block_root, - decode_any_variant: true, _phantom: PhantomData, } } @@ -285,12 +273,10 @@ impl<'a, E: EthSpec, Hot: ItemStore<E>, Cold: ItemStore<E>> Ok(None) } else { let block_root = self.next_block_root; - let block = if self.decode_any_variant { - self.store.get_block_any_variant(&block_root) - } else { - self.store.get_blinded_block(&block_root) - }? - .ok_or(Error::BlockNotFound(block_root))?; + let block = self + .store + .get_blinded_block(&block_root)? + .ok_or(Error::BlockNotFound(block_root))?; self.next_block_root = block.message().parent_root(); Ok(Some((block_root, block))) } diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index ae5b2e1e57..bd8caa3ad5 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -9,13 +9,13 @@ //! tests for implementation examples. pub mod blob_sidecar_list_from_root; pub mod config; -pub mod consensus_context; pub mod errors; mod forwards_iter; pub mod hdiff; pub mod historic_state_cache; pub mod hot_cold_store; mod impls; +pub mod invariants; mod memory_store; pub mod metadata; pub mod metrics; @@ -27,7 +27,6 @@ pub mod iter; pub use self::blob_sidecar_list_from_root::BlobSidecarListFromRoot; pub use self::config::StoreConfig; -pub use self::consensus_context::OnDiskConsensusContext; pub use self::hot_cold_store::{HotColdDB, HotStateSummary, Split}; pub use self::memory_store::MemoryStore; pub use crate::metadata::BlobInfo; @@ -78,11 +77,7 @@ pub trait KeyValueStore<E: EthSpec>: Sync + Send + Sized + 'static { fn compact(&self) -> Result<(), Error> { // Compact state and block related columns as they are likely to have the most churn, // i.e. entries being created and deleted. - for column in [ - DBColumn::BeaconState, - DBColumn::BeaconStateHotSummary, - DBColumn::BeaconBlock, - ] { + for column in [DBColumn::BeaconStateHotSummary, DBColumn::BeaconBlock] { self.compact_column(column)?; } Ok(()) @@ -234,12 +229,14 @@ pub enum StoreOp<'a, E: EthSpec> { PutState(Hash256, &'a BeaconState<E>), PutBlobs(Hash256, BlobSidecarList<E>), PutDataColumns(Hash256, DataColumnSidecarList<E>), + PutPayloadEnvelope(Hash256, Arc<SignedExecutionPayloadEnvelope<E>>), PutStateSummary(Hash256, HotStateSummary), DeleteBlock(Hash256), DeleteBlobs(Hash256), - DeleteDataColumns(Hash256, Vec<ColumnIndex>), + DeleteDataColumns(Hash256, Vec<ColumnIndex>, ForkName), DeleteState(Hash256, Option<Slot>), DeleteExecutionPayload(Hash256), + DeletePayloadEnvelope(Hash256), DeleteSyncCommitteeBranch(Hash256), KeyValueOp(KeyValueStoreOp), } @@ -310,6 +307,9 @@ pub enum DBColumn { /// Execution payloads for blocks more recent than the finalized checkpoint. #[strum(serialize = "exp")] ExecPayload, + /// Post-gloas execution payload envelopes. + #[strum(serialize = "pay")] + PayloadEnvelope, /// For persisting in-memory state to the database. #[strum(serialize = "bch")] BeaconChain, @@ -421,7 +421,8 @@ impl DBColumn { | Self::BeaconRestorePoint | Self::DhtEnrs | Self::CustodyContext - | Self::OptimisticTransitionBlock => 32, + | Self::OptimisticTransitionBlock + | Self::PayloadEnvelope => 32, Self::BeaconBlockRoots | Self::BeaconDataColumnCustodyInfo | Self::BeaconBlockRootsChunked diff --git a/beacon_node/store/src/metadata.rs b/beacon_node/store/src/metadata.rs index cf49468451..215cdb2b64 100644 --- a/beacon_node/store/src/metadata.rs +++ b/beacon_node/store/src/metadata.rs @@ -4,7 +4,7 @@ use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use types::{Hash256, Slot}; -pub const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(28); +pub const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(29); // All the keys that get stored under the `BeaconMeta` column. // diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 4b0d1ee016..6d159c9361 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -111,6 +111,19 @@ impl<E: EthSpec> StateCache<E> { self.hdiff_buffers.mem_usage() } + /// Return all state roots currently held in the cache, including the finalized state. + pub fn state_roots(&self) -> Vec<Hash256> { + let mut roots: Vec<Hash256> = self + .states + .iter() + .map(|(&state_root, _)| state_root) + .collect(); + if let Some(ref finalized) = self.finalized_state { + roots.push(finalized.state_root); + } + roots + } + pub fn update_finalized_state( &mut self, state_root: Hash256, diff --git a/book/src/advanced_checkpoint_sync.md b/book/src/advanced_checkpoint_sync.md index 7c30598928..0682310dcd 100644 --- a/book/src/advanced_checkpoint_sync.md +++ b/book/src/advanced_checkpoint_sync.md @@ -102,7 +102,7 @@ lack of historic states. _You do not need these states to run a staking node_, b for historical API calls (as used by block explorers and researchers). To run an archived node, you can opt-in to reconstructing all of the historic states by providing the -`--reconstruct-historic-states` flag to the beacon node at any point (before, during or after sync). +`--archive` flag to the beacon node at any point (before, during or after sync). The database keeps track of three markers to determine the availability of historic blocks and states: diff --git a/book/src/advanced_networking.md b/book/src/advanced_networking.md index 0dc53bd42a..b14740dc18 100644 --- a/book/src/advanced_networking.md +++ b/book/src/advanced_networking.md @@ -69,7 +69,6 @@ The steps to do port forwarding depends on the router, but the general steps are - On Linux: open a terminal and run `ip route | grep default`, the result should look something similar to `default via 192.168.50.1 dev wlp2s0 proto dhcp metric 600`. The `192.168.50.1` is your router management default gateway IP. - On macOS: open a terminal and run `netstat -nr|grep default` and it should return the default gateway IP. - - On Windows: open a command prompt and run `ipconfig` and look for the `Default Gateway` which will show you the gateway IP. The default gateway IP usually looks like 192.168.X.X. Once you obtain the IP, enter it to a web browser and it will lead you to the router management page. diff --git a/book/src/api_lighthouse.md b/book/src/api_lighthouse.md index 0442bf4ec0..c2e4fbdd5a 100644 --- a/book/src/api_lighthouse.md +++ b/book/src/api_lighthouse.md @@ -512,180 +512,6 @@ As all testnets and Mainnet have been merged, both values will be the same after } ``` -## `/lighthouse/analysis/attestation_performance/{index}` - -Fetch information about the attestation performance of a validator index or all validators for a -range of consecutive epochs. - -Two query parameters are required: - -- `start_epoch` (inclusive): the first epoch to compute attestation performance for. -- `end_epoch` (inclusive): the final epoch to compute attestation performance for. - -Example: - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/attestation_performance/1?start_epoch=1&end_epoch=1" | jq -``` - -```json -[ - { - "index": 1, - "epochs": { - "1": { - "active": true, - "head": true, - "target": true, - "source": true, - "delay": 1 - } - } - } -] -``` - -Instead of specifying a validator index, you can specify the entire validator set by using `global`: - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/attestation_performance/global?start_epoch=1&end_epoch=1" | jq -``` - -```json -[ - { - "index": 0, - "epochs": { - "1": { - "active": true, - "head": true, - "target": true, - "source": true, - "delay": 1 - } - } - }, - { - "index": 1, - "epochs": { - "1": { - "active": true, - "head": true, - "target": true, - "source": true, - "delay": 1 - } - } - }, - { - .. - } -] - -``` - -Caveats: - -- For maximum efficiency the start_epoch should satisfy `(start_epoch * slots_per_epoch) % slots_per_restore_point == 1`. - This is because the state *prior* to the `start_epoch` needs to be loaded from the database, - and loading a state on a boundary is most efficient. - -## `/lighthouse/analysis/block_rewards` - -Fetch information about the block rewards paid to proposers for a range of consecutive blocks. - -Two query parameters are required: - -- `start_slot` (inclusive): the slot of the first block to compute rewards for. -- `end_slot` (inclusive): the slot of the last block to compute rewards for. - -Example: - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/block_rewards?start_slot=1&end_slot=1" | jq -``` - -The first few lines of the response would look like: - -```json -[ - { - "total": 637260, - "block_root": "0x4a089c5e390bb98e66b27358f157df825128ea953cee9d191229c0bcf423a4f6", - "meta": { - "slot": "1", - "parent_slot": "0", - "proposer_index": 93, - "graffiti": "EF #vm-eth2-raw-iron-101" - }, - "attestation_rewards": { - "total": 637260, - "prev_epoch_total": 0, - "curr_epoch_total": 637260, - "per_attestation_rewards": [ - { - "50102": 780, - } - ] - } - } -] -``` - -Caveats: - -- Presently only attestation and sync committee rewards are computed. -- The output format is verbose and subject to change. Please see [`BlockReward`][block_reward_src] - in the source. -- For maximum efficiency the `start_slot` should satisfy `start_slot % slots_per_restore_point == 1`. - This is because the state *prior* to the `start_slot` needs to be loaded from the database, and - loading a state on a boundary is most efficient. - -[block_reward_src]: -https://github.com/sigp/lighthouse/tree/unstable/common/eth2/src/lighthouse/block_rewards.rs - -## `/lighthouse/analysis/block_packing` - -Fetch information about the block packing efficiency of blocks for a range of consecutive -epochs. - -Two query parameters are required: - -- `start_epoch` (inclusive): the epoch of the first block to compute packing efficiency for. -- `end_epoch` (inclusive): the epoch of the last block to compute packing efficiency for. - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/block_packing_efficiency?start_epoch=1&end_epoch=1" | jq -``` - -An excerpt of the response looks like: - -```json -[ - { - "slot": "33", - "block_hash": "0xb20970bb97c6c6de6b1e2b689d6381dd15b3d3518fbaee032229495f963bd5da", - "proposer_info": { - "validator_index": 855, - "graffiti": "poapZoJ7zWNfK7F3nWjEausWVBvKa6gA" - }, - "available_attestations": 3805, - "included_attestations": 1143, - "prior_skip_slots": 1 - }, - { - .. - } -] -``` - -Caveats: - -- `start_epoch` must not be `0`. -- For maximum efficiency the `start_epoch` should satisfy `(start_epoch * slots_per_epoch) % slots_per_restore_point == 1`. - This is because the state *prior* to the `start_epoch` needs to be loaded from the database, and - loading a state on a boundary is most efficient. - ## `/lighthouse/logs` This is a Server Side Event subscription endpoint. This allows a user to read diff --git a/book/src/api_validator_inclusion.md b/book/src/api_validator_inclusion.md index eef563dcdb..d86483e0ea 100644 --- a/book/src/api_validator_inclusion.md +++ b/book/src/api_validator_inclusion.md @@ -8,7 +8,7 @@ These endpoints are not stable or included in the Ethereum consensus standard AP they are subject to change or removal without a change in major release version. -In order to apply these APIs, you need to have historical states information in the database of your node. This means adding the flag `--reconstruct-historic-states` in the beacon node. Once the state reconstruction process is completed, you can apply these APIs to any epoch. +In order to apply these APIs, you need to have historical states information in the database of your node. This means adding the flag `--archive` in the beacon node. Once the state reconstruction process is completed, you can apply these APIs to any epoch. ## Endpoints diff --git a/book/src/api_vc_endpoints.md b/book/src/api_vc_endpoints.md index d128b13b2f..cc9dd362f8 100644 --- a/book/src/api_vc_endpoints.md +++ b/book/src/api_vc_endpoints.md @@ -249,6 +249,7 @@ Example Response Body "FULU_FORK_VERSION": "0x70000910", "FULU_FORK_EPOCH": "18446744073709551615", "SECONDS_PER_SLOT": "12", + "SLOT_DURATION_MS": "12000", "SECONDS_PER_ETH1_BLOCK": "12", "MIN_VALIDATOR_WITHDRAWABILITY_DELAY": "256", "SHARD_COMMITTEE_PERIOD": "256", diff --git a/book/src/contributing_setup.md b/book/src/contributing_setup.md index 958e8f71f6..e2bda0aa5d 100644 --- a/book/src/contributing_setup.md +++ b/book/src/contributing_setup.md @@ -10,9 +10,6 @@ base dependencies. The additional requirements for developers are: -- [`anvil`](https://github.com/foundry-rs/foundry/tree/master/crates/anvil). This is used to - simulate the execution chain during tests. You'll get failures during tests if you - don't have `anvil` available on your `PATH`. - [`cmake`](https://cmake.org/cmake/help/latest/command/install.html). Used by some dependencies. See [`Installation Guide`](./installation.md) for more info. - [`java 17 runtime`](https://openjdk.java.net/projects/jdk/). 17 is the minimum, diff --git a/book/src/faq.md b/book/src/faq.md index c9bc53533f..5ba2c3407f 100644 --- a/book/src/faq.md +++ b/book/src/faq.md @@ -167,19 +167,19 @@ This is a known [bug](https://github.com/sigp/lighthouse/issues/3707) that will ### <a name="bn-partial-history"></a> How can I construct only partial state history? -Lighthouse prunes finalized states by default. Nevertheless, it is quite often that users may be interested in the state history of a few epochs before finalization. To have access to these pruned states, Lighthouse typically requires a full reconstruction of states using the flag `--reconstruct-historic-states` (which will usually take a week). Partial state history can be achieved with some "tricks". Here are the general steps: +Lighthouse prunes finalized states by default. Nevertheless, it is quite often that users may be interested in the state history of a few epochs before finalization. To have access to these pruned states, Lighthouse typically requires a full reconstruction of states using the flag `--archive` (which will usually take a week). Partial state history can be achieved with some "tricks". Here are the general steps: 1. Delete the current database. You can do so with `--purge-db-force` or manually deleting the database from the data directory: `$datadir/beacon`. - 1. If you are interested in the states from the current slot and beyond, perform a checkpoint sync with the flag `--reconstruct-historic-states`, then you can skip the following and jump straight to Step 5 to check the database. + 1. If you are interested in the states from the current slot and beyond, perform a checkpoint sync with the flag `--archive`, then you can skip the following and jump straight to Step 5 to check the database. - If you are interested in the states before the current slot, identify the slot to perform a manual checkpoint sync. With the default configuration, this slot should be divisible by 2<sup>21</sup>, as this is where a full state snapshot is stored. With the flag `--reconstruct-historic-states`, the state upper limit will be adjusted to the next full snapshot slot, a slot that satisfies: `slot % 2**21 == 0`. In other words, to have the state history available before the current slot, we have to checkpoint sync 2<sup>21</sup> slots before the next full snapshot slot. + If you are interested in the states before the current slot, identify the slot to perform a manual checkpoint sync. With the default configuration, this slot should be divisible by 2<sup>21</sup>, as this is where a full state snapshot is stored. With the flag `--archive`, the state upper limit will be adjusted to the next full snapshot slot, a slot that satisfies: `slot % 2**21 == 0`. In other words, to have the state history available before the current slot, we have to checkpoint sync 2<sup>21</sup> slots before the next full snapshot slot. Example: Say the current mainnet is at slot `12000000`. As the next full state snapshot is at slot `12582912`, the slot that we want is slot `10485760`. You can calculate this (in Python) using `12000000 // 2**21 * 2**21`. 1. [Export](./advanced_checkpoint_sync.md#manual-checkpoint-sync) the blobs, block and state data for the slot identified in Step 2. This can be done from another beacon node that you have access to, or you could use any available public beacon API, e.g., [QuickNode](https://www.quicknode.com/docs/ethereum). - 1. Perform a [manual checkpoint sync](./advanced_checkpoint_sync.md#manual-checkpoint-sync) using the data from the previous step, and provide the flag `--reconstruct-historic-states`. + 1. Perform a [manual checkpoint sync](./advanced_checkpoint_sync.md#manual-checkpoint-sync) using the data from the previous step, and provide the flag `--archive`. 1. Check the database: @@ -193,9 +193,9 @@ Lighthouse prunes finalized states by default. Nevertheless, it is quite often t "state_upper_limit": "10485760", ``` -Lighthouse will now start to reconstruct historic states from slot `10485760`. At this point, if you do not want a full state reconstruction, you may remove the flag `--reconstruct-historic-states` (and restart). When the process is completed, you will have the state data from slot `10485760`. Going forward, Lighthouse will continue retaining all historical states newer than the snapshot. Eventually this can lead to increased disk usage, which presently can only be reduced by repeating the process starting from a more recent snapshot. +Lighthouse will now start to reconstruct historic states from slot `10485760`. At this point, if you do not want a full state reconstruction, you may remove the flag `--archive` (and restart). When the process is completed, you will have the state data from slot `10485760`. Going forward, Lighthouse will continue retaining all historical states newer than the snapshot. Eventually this can lead to increased disk usage, which presently can only be reduced by repeating the process starting from a more recent snapshot. -> Note: You may only be interested in very recent historic states. To do so, you may configure the full snapshot to be, for example, every 2<sup>11</sup> slots, see [database configuration](./advanced_database.md#hierarchical-state-diffs) for more details. This can be configured with the flag `--hierarchy-exponents 5,7,11` together with the flag `--reconstruct-historic-states`. This will affect the slot number in Step 2, while other steps remain the same. Note that this comes at the expense of a higher storage requirement. +> Note: You may only be interested in very recent historic states. To do so, you may configure the full snapshot to be, for example, every 2<sup>11</sup> slots, see [database configuration](./advanced_database.md#hierarchical-state-diffs) for more details. This can be configured with the flag `--hierarchy-exponents 5,7,11` together with the flag `--archive`. This will affect the slot number in Step 2, while other steps remain the same. Note that this comes at the expense of a higher storage requirement. > With `--hierarchy-exponents 5,7,11`, using the same example as above, the next full state snapshot is at slot `12001280`. So the slot to checkpoint sync from is: slot `11999232`. diff --git a/book/src/help_bn.md b/book/src/help_bn.md index 5f3c43a7e4..b580bcae52 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -225,7 +225,8 @@ Options: be careful to avoid filling up their disks. --libp2p-addresses <MULTIADDR> One or more comma-delimited multiaddrs to manually connect to a libp2p - peer without an ENR. + peer without an ENR. DEPRECATED. The --libp2p-addresses flag is + deprecated and replaced by --boot-nodes --listen-address [<ADDRESS>...] The address lighthouse will listen for UDP and TCP connections. To listen over IPv4 and IPv6 set this flag twice with the different @@ -438,6 +439,10 @@ Flags: intended for use by block builders, relays and developers. You should set a fee recipient on this BN and also consider adjusting the --prepare-payload-lookahead flag. + --archive + Store all beacon states in the database. When checkpoint syncing, + states are reconstructed after backfill completes. This requires + syncing all the way back to genesis. --builder-fallback-disable-checks This flag disables all checks related to chain health. This means the builder API will always be used for payload construction, regardless @@ -492,6 +497,9 @@ Flags: Sets the local ENR IP address and port to match those set for lighthouse. Specifically, the IP address will be the value of --listen-address and the UDP port will be --discovery-port. + --enable-partial-columns + Enable partial messages for data columns. This can reduce the amount + of data sent over the network. --enable-private-discovery Lighthouse by default does not discover private IP addresses. Set this flag to enable connection attempts to local addresses. @@ -508,6 +516,12 @@ Flags: --http-enable-tls Serves the RESTful HTTP API server over TLS. This feature is currently experimental. + --ignore-ws-check + Using this flag allows a node to run in a state that may expose it to + long-range attacks. For more information please read this blog post: + https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity + If you understand the risks, you can use this flag to disable the Weak + Subjectivity check at startup. --import-all-attestations Import and aggregate all attestations, regardless of validator subscriptions. This will only import attestations from @@ -545,9 +559,6 @@ Flags: --purge-db-force If present, the chain database will be deleted without confirmation. Use with caution. - --reconstruct-historic-states - After a checkpoint sync, reconstruct historic states in the database. - This requires syncing all the way back to genesis. --reset-payload-statuses When present, Lighthouse will forget the payload statuses of any already-imported blocks. This can assist in the recovery from a diff --git a/book/src/help_vc.md b/book/src/help_vc.md index 2a9936d1d2..4647780ea8 100644 --- a/book/src/help_vc.md +++ b/book/src/help_vc.md @@ -185,6 +185,12 @@ Flags: If present, do not attempt to discover new validators in the validators-dir. Validators will need to be manually added to the validator_definitions.yml file. + --disable-beacon-head-monitor + Disable the beacon head monitor which tries to attest as soon as any + of the configured beacon nodes sends a head event. Leaving the service + enabled is recommended, but disabling it can lead to reduced bandwidth + and more predictable usage of the primary beacon node (rather than the + fastest BN). --disable-latency-measurement-service Disables the service that periodically attempts to measure latency to BNs. diff --git a/book/src/help_vm_import.md b/book/src/help_vm_import.md index 3c768f6705..09c1b74f4d 100644 --- a/book/src/help_vm_import.md +++ b/book/src/help_vm_import.md @@ -24,6 +24,9 @@ Options: --debug-level <LEVEL> Specifies the verbosity level used when emitting logs to the terminal. [default: info] [possible values: info, debug, trace, warn, error] + --enabled <enabled> + When provided, the imported validator will be enabled or disabled. + [possible values: true, false] --gas-limit <UINT64> When provided, the imported validator will use this gas limit. It is recommended to leave this as the default value by not specifying this diff --git a/book/src/installation.md b/book/src/installation.md index 95550e0807..15327400e9 100644 --- a/book/src/installation.md +++ b/book/src/installation.md @@ -1,6 +1,8 @@ # 📦 Installation -Lighthouse runs on Linux, macOS, and Windows. +Lighthouse runs on Linux, macOS, and Windows*. + +> \* Lighthouse does not officially support Windows platform. However, Lighthouse can still run natively on Windows until it does not at some point in the future. Windows users may also switch to using Docker, Windows Subsystem for Linux or other supported operating systems. There are three core methods to obtain the Lighthouse application: diff --git a/book/src/installation_binaries.md b/book/src/installation_binaries.md index 67a629e5c3..84e8bc04c4 100644 --- a/book/src/installation_binaries.md +++ b/book/src/installation_binaries.md @@ -11,7 +11,6 @@ Binaries are supplied for the following platforms: - `x86_64-unknown-linux-gnu`: AMD/Intel 64-bit processors (most desktops, laptops, servers) - `aarch64-unknown-linux-gnu`: 64-bit ARM processors (Raspberry Pi 4) - `aarch64-apple-darwin`: macOS with ARM chips -- `x86_64-windows`: Windows with 64-bit processors ## Usage @@ -32,5 +31,3 @@ a `x86_64` binary. 1. Test the binary with `./lighthouse --version` (it should print the version). 1. (Optional) Move the `lighthouse` binary to a location in your `PATH`, so the `lighthouse` command can be called from anywhere. For example, to copy `lighthouse` from the current directory to `usr/bin`, run `sudo cp lighthouse /usr/bin`. - -> Windows users will need to execute the commands in Step 2 from PowerShell. diff --git a/book/src/installation_source.md b/book/src/installation_source.md index f035d1d843..4a5c3d9922 100644 --- a/book/src/installation_source.md +++ b/book/src/installation_source.md @@ -69,6 +69,8 @@ After this, you are ready to [build Lighthouse](#build-lighthouse). ### Windows +> Note: Lighthouse no longer officially supports Windows binary. However, users will still be able to build the binary from source according to the instructions below. The instructions may no longer work at some point in the future. + 1. Install [Git](https://git-scm.com/download/win). 1. Install the [Chocolatey](https://chocolatey.org/install) package manager for Windows. > Tips: diff --git a/book/src/validator_doppelganger.md b/book/src/validator_doppelganger.md index 006df50bd9..6e199546f5 100644 --- a/book/src/validator_doppelganger.md +++ b/book/src/validator_doppelganger.md @@ -106,7 +106,6 @@ The steps to solving a doppelganger vary depending on the case, but some places 1. Is there another validator process running on this host? - Unix users can check by running the command `ps aux | grep lighthouse` - - Windows users can check the Task Manager. 1. Has this validator recently been moved from another host? Check to ensure it's not running. 1. Has this validator been delegated to a staking service? diff --git a/boot_node/src/config.rs b/boot_node/src/config.rs index fb0daf5264..5b13b95c97 100644 --- a/boot_node/src/config.rs +++ b/boot_node/src/config.rs @@ -14,6 +14,7 @@ use ssz::Encode; use std::net::{SocketAddrV4, SocketAddrV6}; use std::time::Duration; use std::{marker::PhantomData, path::PathBuf}; +use tracing::{info, warn}; use types::EthSpec; /// A set of configuration parameters for the bootnode, established from CLI arguments. @@ -117,7 +118,7 @@ impl<E: EthSpec> BootNodeConfig<E> { let genesis_state_root = genesis_state .canonical_root() .map_err(|e| format!("Error hashing genesis state: {e:?}"))?; - tracing::info!(root = ?genesis_state_root, "Genesis state found"); + info!(root = ?genesis_state_root, "Genesis state found"); let enr_fork = spec.enr_fork_id::<E>( types::Slot::from(0u64), genesis_state.genesis_validators_root(), @@ -125,7 +126,7 @@ impl<E: EthSpec> BootNodeConfig<E> { Some(enr_fork.as_ssz_bytes()) } else { - tracing::warn!("No genesis state provided. No Eth2 field added to the ENR"); + warn!("No genesis state provided. No Eth2 field added to the ENR"); None } }; diff --git a/common/account_utils/Cargo.toml b/common/account_utils/Cargo.toml index d0a3e487c4..b5c84bbb64 100644 --- a/common/account_utils/Cargo.toml +++ b/common/account_utils/Cargo.toml @@ -14,8 +14,8 @@ rand = { workspace = true } regex = { workspace = true } rpassword = "5.0.0" serde = { workspace = true } -serde_yaml = { workspace = true } tracing = { workspace = true } types = { workspace = true } validator_dir = { workspace = true } +yaml_serde = { workspace = true } zeroize = { workspace = true } diff --git a/common/account_utils/src/validator_definitions.rs b/common/account_utils/src/validator_definitions.rs index bffdfcc38b..fe6481350c 100644 --- a/common/account_utils/src/validator_definitions.rs +++ b/common/account_utils/src/validator_definitions.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use std::fs::{self, File, create_dir_all}; use std::io; use std::path::{Path, PathBuf}; -use tracing::error; +use tracing::{debug, error}; use types::{Address, graffiti::GraffitiString}; use validator_dir::VOTING_KEYSTORE_FILE; use zeroize::Zeroizing; @@ -31,11 +31,11 @@ pub enum Error { /// The config file could not be opened. UnableToOpenFile(io::Error), /// The config file could not be parsed as YAML. - UnableToParseFile(serde_yaml::Error), + UnableToParseFile(yaml_serde::Error), /// There was an error whilst performing the recursive keystore search function. UnableToSearchForKeystores(io::Error), /// The config file could not be serialized as YAML. - UnableToEncodeFile(serde_yaml::Error), + UnableToEncodeFile(yaml_serde::Error), /// The config file or temp file could not be written to the filesystem. UnableToWriteFile(filesystem::Error), /// The public key from the keystore is invalid. @@ -212,6 +212,16 @@ impl ValidatorDefinition { }, }) } + + pub fn check_fee_recipient(&self, global_fee_recipient: Option<Address>) -> Option<&PublicKey> { + // Skip disabled validators. Also skip if validator has its own fee set, or the global flag is set + if !self.enabled || self.suggested_fee_recipient.is_some() || global_fee_recipient.is_some() + { + return None; + } + + Some(&self.voting_public_key) + } } /// A list of `ValidatorDefinition` that serves as a serde-able configuration file which defines a @@ -248,7 +258,7 @@ impl ValidatorDefinitions { .create_new(false) .open(config_path) .map_err(Error::UnableToOpenFile)?; - serde_yaml::from_reader(file).map_err(Error::UnableToParseFile) + yaml_serde::from_reader(file).map_err(Error::UnableToParseFile) } /// Perform a recursive, exhaustive search through `validators_dir` and add any keystores @@ -376,7 +386,7 @@ impl ValidatorDefinitions { let config_path = validators_dir.as_ref().join(CONFIG_FILENAME); let temp_path = validators_dir.as_ref().join(CONFIG_TEMP_FILENAME); let mut bytes = vec![]; - serde_yaml::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; + yaml_serde::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; write_file_via_temporary(&config_path, &temp_path, &bytes) .map_err(Error::UnableToWriteFile)?; @@ -410,6 +420,52 @@ impl ValidatorDefinitions { .iter() .filter_map(|def| def.signing_definition.voting_keystore_password_path()) } + + /// Called after loading to run safety checks on all validators + pub fn check_all_fee_recipients( + &self, + global_fee_recipient: Option<Address>, + ) -> Result<(), String> { + let missing: Vec<&PublicKey> = self + .0 + .iter() + .filter_map(|def| def.check_fee_recipient(global_fee_recipient)) + .collect(); + + if !missing.is_empty() { + let pubkeys = missing + .iter() + .map(|pk| pk.to_string()) + .collect::<Vec<_>>() + .join(", "); + + return Err(format!( + "The following validators are missing a `suggested_fee_recipient`: {}. \ + Fix this by adding a `suggested_fee_recipient` in the \ + `validator_definitions.yml` or by supplying a fallback fee \ + recipient via the `--suggested-fee-recipient` flag.", + pubkeys + )); + } + + // Friendly reminder for users using the fallback flag + if global_fee_recipient.is_some() { + let count = self + .0 + .iter() + .filter(|d| d.enabled && d.suggested_fee_recipient.is_none()) + .count(); + if count > 0 { + debug!( + "The fallback --suggested-fee-recipient is being used for {} validator(s). \ + You may alternatively set the fee recipient for each validator individually via `validator_definitions.yml`.", + count + ); + } + } + + Ok(()) + } } /// Perform an exhaustive tree search of `dir`, adding any discovered voting keystore paths to @@ -485,6 +541,7 @@ pub fn is_voting_keystore(file_name: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use bls::Keypair; use std::str::FromStr; #[test] @@ -531,7 +588,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_graffiti).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_graffiti).unwrap(); assert!(def.graffiti.is_none()); let invalid_graffiti = r#"--- @@ -543,7 +600,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: Result<ValidatorDefinition, _> = serde_yaml::from_str(invalid_graffiti); + let def: Result<ValidatorDefinition, _> = yaml_serde::from_str(invalid_graffiti); assert!(def.is_err()); let valid_graffiti = r#"--- @@ -555,7 +612,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_graffiti).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_graffiti).unwrap(); assert_eq!( def.graffiti, Some(GraffitiString::from_str("mrfwashere").unwrap()) @@ -571,7 +628,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_suggested_fee_recipient).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_suggested_fee_recipient).unwrap(); assert!(def.suggested_fee_recipient.is_none()); let invalid_suggested_fee_recipient = r#"--- @@ -584,7 +641,7 @@ mod tests { "#; let def: Result<ValidatorDefinition, _> = - serde_yaml::from_str(invalid_suggested_fee_recipient); + yaml_serde::from_str(invalid_suggested_fee_recipient); assert!(def.is_err()); let valid_suggested_fee_recipient = r#"--- @@ -596,7 +653,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_suggested_fee_recipient).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_suggested_fee_recipient).unwrap(); assert_eq!( def.suggested_fee_recipient, Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()) @@ -613,7 +670,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_gas_limit).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_gas_limit).unwrap(); assert!(def.gas_limit.is_none()); let invalid_gas_limit = r#"--- @@ -626,7 +683,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: Result<ValidatorDefinition, _> = serde_yaml::from_str(invalid_gas_limit); + let def: Result<ValidatorDefinition, _> = yaml_serde::from_str(invalid_gas_limit); assert!(def.is_err()); let valid_gas_limit = r#"--- @@ -639,7 +696,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_gas_limit).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_gas_limit).unwrap(); assert_eq!(def.gas_limit, Some(35000000)); } @@ -653,7 +710,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_builder_proposals).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_builder_proposals).unwrap(); assert!(def.builder_proposals.is_none()); let invalid_builder_proposals = r#"--- @@ -666,7 +723,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: Result<ValidatorDefinition, _> = serde_yaml::from_str(invalid_builder_proposals); + let def: Result<ValidatorDefinition, _> = yaml_serde::from_str(invalid_builder_proposals); assert!(def.is_err()); let valid_builder_proposals = r#"--- @@ -679,7 +736,238 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_builder_proposals).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_builder_proposals).unwrap(); assert_eq!(def.builder_proposals, Some(true)); } + + #[test] + fn fee_recipient_check_enabled_validator_cases() { + let def = ValidatorDefinition { + enabled: true, + voting_public_key: PublicKey::from_str( + "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" + ).unwrap(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + } + }; + + // Should return Some(pubkey) when no fee recipient is set + let check_result = def.check_fee_recipient(None); + assert!(check_result.is_some()); + + // Should return None since global fee recipient is set + let global_fee_recipient = + Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()); + let check_result = def.check_fee_recipient(global_fee_recipient); + assert!(check_result.is_none()); + } + + #[test] + fn fee_recipient_check_passes_with_validator_specific() { + let def = ValidatorDefinition { + enabled: true, + voting_public_key: PublicKey::from_str( + "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" + ).unwrap(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()), + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + // Should return None because suggested_fee_recipient is set + let check_result = def.check_fee_recipient(None); + assert!(check_result.is_none()); + } + + #[test] + fn fee_recipient_check_skips_disabled_validators() { + let def = ValidatorDefinition { + enabled: false, + voting_public_key: PublicKey::from_str( + "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" + ).unwrap(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + // Should return None because validator is disabled + let check_result = def.check_fee_recipient(None); + assert!(check_result.is_none()); + } + + #[test] + fn check_all_fee_recipients_reports_all_missing() { + let keypair1 = Keypair::random(); + let keypair2 = Keypair::random(); + + let def1 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair1.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let def2 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair2.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, // Missing recipient + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let defs = ValidatorDefinitions::from(vec![def1, def2]); + + // Should fail because both defs have no fee recipient and no global fee recipient is set + let result = defs.check_all_fee_recipients(None); + assert!(result.is_err()); + let err = result.unwrap_err(); + + // Check that both public keys are mentioned in the error message + let pk1_string = keypair1.pk.to_string(); + let pk2_string = keypair2.pk.to_string(); + + assert!(err.contains(&pk1_string), "Error message missing pubkey 1"); + assert!(err.contains(&pk2_string), "Error message missing pubkey 2"); + assert!(err.contains("are missing a `suggested_fee_recipient`")); + } + + #[test] + fn check_all_fee_recipients_passes_all_configured() { + let keypair = Keypair::random(); + let def1 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: Some( + Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap(), + ), + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let def2 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: Some( + Address::from_str("0xb2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap(), + ), + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let defs = ValidatorDefinitions::from(vec![def1, def2]); + + // Should pass - all validators have fee recipients + assert!(defs.check_all_fee_recipients(None).is_ok()); + } + + #[test] + fn check_all_fee_recipients_passes_with_global() { + let keypair = Keypair::random(); + let def1 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let def2 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let defs = ValidatorDefinitions::from(vec![def1, def2]); + + // Should pass - global fee recipient is set + let global_fee_recipient = + Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()); + assert!(defs.check_all_fee_recipients(global_fee_recipient).is_ok()); + } } diff --git a/common/clap_utils/Cargo.toml b/common/clap_utils/Cargo.toml index f3c166bda9..02c9ac97f1 100644 --- a/common/clap_utils/Cargo.toml +++ b/common/clap_utils/Cargo.toml @@ -14,5 +14,5 @@ ethereum_ssz = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } types = { workspace = true } +yaml_serde = { workspace = true } diff --git a/common/clap_utils/src/lib.rs b/common/clap_utils/src/lib.rs index bc904c78e3..e969ab95d4 100644 --- a/common/clap_utils/src/lib.rs +++ b/common/clap_utils/src/lib.rs @@ -159,7 +159,7 @@ where let chain_config = Config::from_chain_spec::<E>(spec); let mut file = std::fs::File::create(dump_path) .map_err(|e| format!("Failed to open file for writing chain config: {:?}", e))?; - serde_yaml::to_writer(&mut file, &chain_config) + yaml_serde::to_writer(&mut file, &chain_config) .map_err(|e| format!("Error serializing config: {:?}", e))?; } Ok(()) diff --git a/common/eip_3076/Cargo.toml b/common/eip_3076/Cargo.toml index 058e1fd1a0..157fe12cb3 100644 --- a/common/eip_3076/Cargo.toml +++ b/common/eip_3076/Cargo.toml @@ -6,7 +6,7 @@ edition = { workspace = true } [features] default = [] -arbitrary-fuzz = ["dep:arbitrary", "types/arbitrary"] +arbitrary = ["dep:arbitrary", "types/arbitrary"] json = ["dep:serde_json"] [dependencies] diff --git a/common/eip_3076/src/lib.rs b/common/eip_3076/src/lib.rs index cdd05d7b1e..0bf1a94d0e 100644 --- a/common/eip_3076/src/lib.rs +++ b/common/eip_3076/src/lib.rs @@ -13,7 +13,7 @@ pub enum Error { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InterchangeMetadata { #[serde(with = "serde_utils::quoted_u64::require_quotes")] pub interchange_format_version: u64, @@ -22,7 +22,7 @@ pub struct InterchangeMetadata { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InterchangeData { pub pubkey: PublicKeyBytes, pub signed_blocks: Vec<SignedBlock>, @@ -31,7 +31,7 @@ pub struct InterchangeData { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SignedBlock { #[serde(with = "serde_utils::quoted_u64::require_quotes")] pub slot: Slot, @@ -41,7 +41,7 @@ pub struct SignedBlock { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SignedAttestation { #[serde(with = "serde_utils::quoted_u64::require_quotes")] pub source_epoch: Epoch, @@ -52,7 +52,7 @@ pub struct SignedAttestation { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Interchange { pub metadata: InterchangeMetadata, pub data: Vec<InterchangeData>, diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index da8aba5ded..974508492a 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -8,19 +8,23 @@ edition = { workspace = true } default = [] lighthouse = ["proto_array", "eth2_keystore", "eip_3076", "zeroize"] events = ["reqwest-eventsource", "futures", "futures-util"] +network = ["libp2p-identity", "enr", "multiaddr"] [dependencies] bls = { workspace = true } context_deserialize = { workspace = true } educe = { workspace = true } eip_3076 = { workspace = true, optional = true } +enr = { version = "0.13.0", features = ["ed25519"], optional = true } eth2_keystore = { workspace = true, optional = true } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } ethereum_ssz_derive = { workspace = true } futures = { workspace = true, optional = true } futures-util = { version = "0.3.8", optional = true } +libp2p-identity = { version = "0.2", features = ["peerid"], optional = true } mediatype = "0.19.13" +multiaddr = { version = "0.18.2", optional = true } pretty_reqwest_error = { workspace = true } proto_array = { workspace = true, optional = true } reqwest = { workspace = true } diff --git a/common/eth2/src/error.rs b/common/eth2/src/error.rs index 1f21220b79..45f2599493 100644 --- a/common/eth2/src/error.rs +++ b/common/eth2/src/error.rs @@ -17,6 +17,8 @@ pub enum Error { #[cfg(feature = "events")] /// The `reqwest_eventsource` client raised an error. SseClient(Box<reqwest_eventsource::Error>), + #[cfg(feature = "events")] + SseEventSource(reqwest_eventsource::CannotCloneRequestError), /// The server returned an error message where the body was able to be parsed. ServerMessage(ErrorMessage), /// The server returned an error message with an array of errors. @@ -100,6 +102,8 @@ impl Error { None } } + #[cfg(feature = "events")] + Error::SseEventSource(_) => None, Error::ServerMessage(msg) => StatusCode::try_from(msg.code).ok(), Error::ServerIndexedMessage(msg) => StatusCode::try_from(msg.code).ok(), Error::StatusCode(status) => Some(*status), diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 2b542f4024..0a9d80ad89 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -22,8 +22,6 @@ pub use beacon_response::{ }; pub use self::error::{Error, ok_or_error, success_or_error}; -pub use reqwest; -pub use reqwest::{StatusCode, Url}; pub use sensitive_url::SensitiveUrl; use self::mixin::{RequestAccept, ResponseOptional}; @@ -35,21 +33,25 @@ use educe::Educe; use futures::Stream; #[cfg(feature = "events")] use futures_util::StreamExt; +#[cfg(feature = "network")] +use libp2p_identity::PeerId; use reqwest::{ - Body, IntoUrl, RequestBuilder, Response, + Body, IntoUrl, RequestBuilder, Response, StatusCode, Url, header::{HeaderMap, HeaderValue}, }; #[cfg(feature = "events")] -use reqwest_eventsource::{Event, EventSource}; +use reqwest_eventsource::{Event, RequestBuilderExt}; use serde::{Serialize, de::DeserializeOwned}; -use ssz::Encode; +use ssz::{Decode, Encode}; use std::fmt; use std::future::Future; use std::time::Duration; +use types::{PayloadAttestationData, PayloadAttestationMessage}; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); pub const V3: EndpointVersion = EndpointVersion(3); +pub const V4: EndpointVersion = EndpointVersion(4); pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; pub const EXECUTION_PAYLOAD_BLINDED_HEADER: &str = "Eth-Execution-Payload-Blinded"; @@ -72,10 +74,13 @@ const HTTP_PROPOSER_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_COMMITTEE_CONTRIBUTION_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT: u32 = 24; // For DVT involving middleware only +// TODO(EIP-7732): Determine what this quotient should be +const HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; const HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT: u32 = 4; +const HTTP_PAYLOAD_ATTESTATION_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_DEFAULT_TIMEOUT_QUOTIENT: u32 = 4; /// A struct to define a variety of different timeouts for different validator tasks to ensure @@ -94,10 +99,12 @@ pub struct Timeouts { pub sync_aggregators: Duration, pub inclusion_list: Duration, pub inclusion_list_duties: Duration, + pub ptc_duties: Duration, pub get_beacon_blocks_ssz: Duration, pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, pub get_validator_block: Duration, + pub payload_attestation: Duration, pub default: Duration, } @@ -116,10 +123,12 @@ impl Timeouts { sync_aggregators: timeout, inclusion_list: timeout, inclusion_list_duties: timeout, + ptc_duties: timeout, get_beacon_blocks_ssz: timeout, get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, get_validator_block: timeout, + payload_attestation: timeout, default: timeout, } } @@ -141,10 +150,12 @@ impl Timeouts { inclusion_list_duties: base_timeout, inclusion_list: base_timeout, sync_aggregators: base_timeout / HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT, + ptc_duties: base_timeout / HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT, get_beacon_blocks_ssz: base_timeout / HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT, get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, get_validator_block: base_timeout / HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT, + payload_attestation: base_timeout / HTTP_PAYLOAD_ATTESTATION_TIMEOUT_QUOTIENT, default: base_timeout / HTTP_DEFAULT_TIMEOUT_QUOTIENT, } } @@ -904,6 +915,47 @@ impl BeaconNodeHttpClient { .map(|opt| opt.map(BeaconResponse::ForkVersioned)) } + /// `GET beacon/states/{state_id}/proposer_lookahead` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_states_proposer_lookahead( + &self, + state_id: StateId, + ) -> Result<Option<ExecutionOptimisticFinalizedBeaconResponse<ValidatorIndexData>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("proposer_lookahead"); + + self.get_fork_contextual(path, |fork| fork) + .await + .map(|opt| opt.map(BeaconResponse::ForkVersioned)) + } + + /// `GET beacon/states/{state_id}/proposer_lookahead` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_states_proposer_lookahead_ssz( + &self, + state_id: StateId, + ) -> Result<Option<Vec<u8>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("proposer_lookahead"); + + self.get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.default) + .await + } + /// `GET beacon/light_client/updates` /// /// Returns `Ok(None)` on a 404 error. @@ -1744,6 +1796,48 @@ impl BeaconNodeHttpClient { Ok(()) } + /// `POST beacon/pool/payload_attestations` (JSON) + pub async fn post_beacon_pool_payload_attestations( + &self, + messages: &[PayloadAttestationMessage], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("pool") + .push("payload_attestations"); + + self.post_generic_with_consensus_version(path, &messages, None, fork_name) + .await?; + + Ok(()) + } + + /// `POST beacon/pool/payload_attestations` (SSZ) + pub async fn post_beacon_pool_payload_attestations_ssz( + &self, + messages: &[PayloadAttestationMessage], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("pool") + .push("payload_attestations"); + + let ssz_body: Vec<u8> = messages.iter().flat_map(|m| m.as_ssz_bytes()).collect(); + + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `POST beacon/pool/bls_to_execution_changes` pub async fn post_beacon_pool_bls_to_execution_changes( &self, @@ -1786,7 +1880,7 @@ impl BeaconNodeHttpClient { &self, block_id: BlockId, validators: &[ValidatorId], - ) -> Result<GenericResponse<Vec<SyncCommitteeReward>>, Error> { + ) -> Result<ExecutionOptimisticFinalizedResponse<Vec<SyncCommitteeReward>>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -1803,7 +1897,7 @@ impl BeaconNodeHttpClient { pub async fn get_beacon_rewards_blocks( &self, block_id: BlockId, - ) -> Result<GenericResponse<StandardBlockReward>, Error> { + ) -> Result<ExecutionOptimisticFinalizedResponse<StandardBlockReward>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -1821,7 +1915,7 @@ impl BeaconNodeHttpClient { &self, epoch: Epoch, validators: &[ValidatorId], - ) -> Result<StandardAttestationRewards, Error> { + ) -> Result<ExecutionOptimisticFinalizedResponse<StandardAttestationRewards>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -1960,6 +2054,7 @@ impl BeaconNodeHttpClient { } /// `GET node/identity` + #[cfg(feature = "network")] pub async fn get_node_identity(&self) -> Result<GenericResponse<IdentityData>, Error> { let mut path = self.eth_path(V1)?; @@ -2007,9 +2102,10 @@ impl BeaconNodeHttpClient { } /// `GET node/peers/{peer_id}` + #[cfg(feature = "network")] pub async fn get_node_peers_by_id( &self, - peer_id: &str, + peer_id: PeerId, ) -> Result<GenericResponse<PeerData>, Error> { let mut path = self.eth_path(V1)?; @@ -2017,7 +2113,7 @@ impl BeaconNodeHttpClient { .map_err(|()| Error::InvalidUrl(self.server.clone()))? .push("node") .push("peers") - .push(peer_id); + .push(&peer_id.to_string()); self.get(path).await } @@ -2167,6 +2263,24 @@ impl BeaconNodeHttpClient { .await } + /// `GET v2/validator/duties/proposer/{epoch}` + pub async fn get_validator_duties_proposer_v2( + &self, + epoch: Epoch, + ) -> Result<DutiesResponse<Vec<ProposerData>>, Error> { + let mut path = self.eth_path(V2)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("proposer") + .push(&epoch.to_string()); + + self.get_with_timeout(path, self.timeouts.proposer_duties) + .await + } + /// `GET v2/validator/blocks/{slot}` pub async fn get_validator_blocks<E: EthSpec>( &self, @@ -2425,6 +2539,326 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } + /// returns `GET v4/validator/blocks/{slot}` URL path + pub async fn get_validator_blocks_v4_path( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_booster_factor: Option<u64>, + graffiti_policy: Option<GraffitiPolicy>, + ) -> Result<Url, Error> { + let mut path = self.eth_path(V4)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("blocks") + .push(&slot.to_string()); + + path.query_pairs_mut() + .append_pair("randao_reveal", &randao_reveal.to_string()); + + if let Some(graffiti) = graffiti { + path.query_pairs_mut() + .append_pair("graffiti", &graffiti.to_string()); + } + + if skip_randao_verification == SkipRandaoVerification::Yes { + path.query_pairs_mut() + .append_pair("skip_randao_verification", ""); + } + + if let Some(builder_booster_factor) = builder_booster_factor { + path.query_pairs_mut() + .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + } + + if let Some(GraffitiPolicy::AppendClientVersions) = graffiti_policy { + path.query_pairs_mut() + .append_pair("graffiti_policy", "AppendClientVersions"); + } + + Ok(path) + } + + /// `GET v4/validator/blocks/{slot}` + pub async fn get_validator_blocks_v4<E: EthSpec>( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + builder_booster_factor: Option<u64>, + graffiti_policy: Option<GraffitiPolicy>, + ) -> Result< + ( + ForkVersionedResponse<BeaconBlock<E>, ProduceBlockV4Metadata>, + ProduceBlockV4Metadata, + ), + Error, + > { + self.get_validator_blocks_v4_modular( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + builder_booster_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` + pub async fn get_validator_blocks_v4_modular<E: EthSpec>( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_booster_factor: Option<u64>, + graffiti_policy: Option<GraffitiPolicy>, + ) -> Result< + ( + ForkVersionedResponse<BeaconBlock<E>, ProduceBlockV4Metadata>, + ProduceBlockV4Metadata, + ), + Error, + > { + let path = self + .get_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_booster_factor, + graffiti_policy, + ) + .await?; + + let opt_result = self + .get_response_with_response_headers( + path, + Accept::Json, + self.timeouts.get_validator_block, + |response, headers| async move { + let header_metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let block_response = response + .json::<ForkVersionedResponse<BeaconBlock<E>, ProduceBlockV4Metadata>>() + .await?; + Ok((block_response, header_metadata)) + }, + ) + .await?; + + opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `GET v4/validator/blocks/{slot}` in ssz format + pub async fn get_validator_blocks_v4_ssz<E: EthSpec>( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + builder_booster_factor: Option<u64>, + graffiti_policy: Option<GraffitiPolicy>, + ) -> Result<(BeaconBlock<E>, ProduceBlockV4Metadata), Error> { + self.get_validator_blocks_v4_modular_ssz::<E>( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + builder_booster_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` in ssz format + pub async fn get_validator_blocks_v4_modular_ssz<E: EthSpec>( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_booster_factor: Option<u64>, + graffiti_policy: Option<GraffitiPolicy>, + ) -> Result<(BeaconBlock<E>, ProduceBlockV4Metadata), Error> { + let path = self + .get_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_booster_factor, + graffiti_policy, + ) + .await?; + + let opt_response = self + .get_response_with_response_headers( + path, + Accept::Ssz, + self.timeouts.get_validator_block, + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let response_bytes = response.bytes().await?; + + let block = BeaconBlock::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?; + + Ok((block, metadata)) + }, + ) + .await?; + + opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` + pub async fn get_validator_execution_payload_envelope<E: EthSpec>( + &self, + slot: Slot, + builder_index: u64, + ) -> Result<ForkVersionedResponse<ExecutionPayloadEnvelope<E>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("execution_payload_envelope") + .push(&slot.to_string()) + .push(&builder_index.to_string()); + + self.get(path).await + } + + /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` in SSZ format + pub async fn get_validator_execution_payload_envelope_ssz<E: EthSpec>( + &self, + slot: Slot, + builder_index: u64, + ) -> Result<ExecutionPayloadEnvelope<E>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("execution_payload_envelope") + .push(&slot.to_string()) + .push(&builder_index.to_string()); + + let opt_response = self + .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.get_validator_block) + .await?; + + let response_bytes = opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND))?; + + ExecutionPayloadEnvelope::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) + } + + /// `POST v1/beacon/execution_payload_envelope` + pub async fn post_beacon_execution_payload_envelope<E: EthSpec>( + &self, + envelope: &SignedExecutionPayloadEnvelope<E>, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope"); + + self.post_generic_with_consensus_version( + path, + envelope, + Some(self.timeouts.proposal), + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST v1/beacon/execution_payload_envelope` in SSZ format + pub async fn post_beacon_execution_payload_envelope_ssz<E: EthSpec>( + &self, + envelope: &SignedExecutionPayloadEnvelope<E>, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope"); + + self.post_generic_with_consensus_version_and_ssz_body( + path, + envelope.as_ssz_bytes(), + Some(self.timeouts.proposal), + fork_name, + ) + .await?; + + Ok(()) + } + + /// Path for `v1/beacon/execution_payload_envelope/{block_id}` + pub fn get_beacon_execution_payload_envelope_path( + &self, + block_id: BlockId, + ) -> Result<Url, Error> { + let mut path = self.eth_path(V1)?; + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope") + .push(&block_id.to_string()); + Ok(path) + } + + /// `GET v1/beacon/execution_payload_envelope/{block_id}` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_execution_payload_envelope<E: EthSpec>( + &self, + block_id: BlockId, + ) -> Result< + Option<ExecutionOptimisticFinalizedBeaconResponse<SignedExecutionPayloadEnvelope<E>>>, + Error, + > { + let path = self.get_beacon_execution_payload_envelope_path(block_id)?; + self.get_opt(path) + .await + .map(|opt| opt.map(BeaconResponse::ForkVersioned)) + } + + /// `GET v1/beacon/execution_payload_envelope/{block_id}` in SSZ format + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_execution_payload_envelope_ssz<E: EthSpec>( + &self, + block_id: BlockId, + ) -> Result<Option<SignedExecutionPayloadEnvelope<E>>, Error> { + let path = self.get_beacon_execution_payload_envelope_path(block_id)?; + let opt_response = self + .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.get_beacon_blocks_ssz) + .await?; + match opt_response { + Some(bytes) => SignedExecutionPayloadEnvelope::from_ssz_bytes(&bytes) + .map(Some) + .map_err(Error::InvalidSsz), + None => Ok(None), + } + } + /// `GET v2/validator/blocks/{slot}` in ssz format pub async fn get_validator_blocks_ssz<E: EthSpec>( &self, @@ -2581,6 +3015,46 @@ impl BeaconNodeHttpClient { self.get_with_timeout(path, self.timeouts.attestation).await } + /// `GET validator/payload_attestation_data/{slot}` + pub async fn get_validator_payload_attestation_data( + &self, + slot: Slot, + ) -> Result<BeaconResponse<PayloadAttestationData>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("payload_attestation_data") + .push(&slot.to_string()); + + self.get_with_timeout(path, self.timeouts.payload_attestation) + .await + .map(BeaconResponse::ForkVersioned) + } + + /// `GET validator/payload_attestation_data/{slot}` in SSZ format + pub async fn get_validator_payload_attestation_data_ssz( + &self, + slot: Slot, + ) -> Result<PayloadAttestationData, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("payload_attestation_data") + .push(&slot.to_string()); + + let opt_response = self + .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.payload_attestation) + .await?; + + let response_bytes = opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND))?; + + PayloadAttestationData::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) + } + /// `GET v1/validator/aggregate_attestation?slot,attestation_data_root` pub async fn get_validator_aggregate_attestation_v1<E: EthSpec>( &self, @@ -2868,7 +3342,16 @@ impl BeaconNodeHttpClient { .join(","); path.query_pairs_mut().append_pair("topics", &topic_string); - let mut es = EventSource::get(path); + // Do not use a timeout for the events endpoint. Using a regular timeout will trigger a + // timeout every `timeout` seconds, regardless of any data streamed from the endpoint. + // In future we could add a read_timeout, but that can only be configured globally on the + // Client. + let mut es = self + .client + .get(path) + .timeout(Duration::MAX) + .eventsource() + .map_err(Error::SseEventSource)?; // If we don't await `Event::Open` here, then the consumer // will not get any Message events until they start awaiting the stream. // This is a way to register the stream with the sse server before @@ -2951,4 +3434,29 @@ impl BeaconNodeHttpClient { self.post_with_timeout_and_response(path, &selections, self.timeouts.sync_aggregators) .await } + + // TODO(EIP-7732): Create corresponding beacon node response endpoint per spec + // https://github.com/ethereum/beacon-APIs/pull/552 + /// `POST validator/duties/ptc/{epoch}` + pub async fn post_validator_duties_ptc( + &self, + epoch: Epoch, + indices: &[u64], + ) -> Result<DutiesResponse<Vec<PtcDuty>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("ptc") + .push(&epoch.to_string()); + + self.post_with_timeout_and_response( + path, + &ValidatorIndexDataRef(indices), + self.timeouts.ptc_duties, + ) + .await + } } diff --git a/common/eth2/src/lighthouse.rs b/common/eth2/src/lighthouse.rs index 993c263cbf..5ff7a7e0f0 100644 --- a/common/eth2/src/lighthouse.rs +++ b/common/eth2/src/lighthouse.rs @@ -1,13 +1,10 @@ //! This module contains endpoints that are non-standard and only available on Lighthouse servers. -mod attestation_performance; -mod block_packing_efficiency; -mod block_rewards; mod custody; pub mod sync_state; use crate::{ - BeaconNodeHttpClient, DepositData, Error, Hash256, Slot, + BeaconNodeHttpClient, DepositData, Error, Hash256, lighthouse::sync_state::SyncState, types::{AdminPeer, Epoch, GenericResponse, ValidatorId}, }; @@ -16,13 +13,6 @@ use serde::{Deserialize, Serialize}; use ssz::four_byte_option_impl; use ssz_derive::{Decode, Encode}; -pub use attestation_performance::{ - AttestationPerformance, AttestationPerformanceQuery, AttestationPerformanceStatistics, -}; -pub use block_packing_efficiency::{ - BlockPackingEfficiency, BlockPackingEfficiencyQuery, ProposerInfo, UniqueAttestation, -}; -pub use block_rewards::{AttestationRewards, BlockReward, BlockRewardMeta, BlockRewardsQuery}; pub use custody::CustodyInfo; // Define "legacy" implementations of `Option<T>` which use four bytes for encoding the union @@ -312,73 +302,4 @@ impl BeaconNodeHttpClient { self.post_with_response(path, &req).await } - - /* - Analysis endpoints. - */ - - /// `GET` lighthouse/analysis/block_rewards?start_slot,end_slot - pub async fn get_lighthouse_analysis_block_rewards( - &self, - start_slot: Slot, - end_slot: Slot, - ) -> Result<Vec<BlockReward>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("lighthouse") - .push("analysis") - .push("block_rewards"); - - path.query_pairs_mut() - .append_pair("start_slot", &start_slot.to_string()) - .append_pair("end_slot", &end_slot.to_string()); - - self.get(path).await - } - - /// `GET` lighthouse/analysis/block_packing?start_epoch,end_epoch - pub async fn get_lighthouse_analysis_block_packing( - &self, - start_epoch: Epoch, - end_epoch: Epoch, - ) -> Result<Vec<BlockPackingEfficiency>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("lighthouse") - .push("analysis") - .push("block_packing_efficiency"); - - path.query_pairs_mut() - .append_pair("start_epoch", &start_epoch.to_string()) - .append_pair("end_epoch", &end_epoch.to_string()); - - self.get(path).await - } - - /// `GET` lighthouse/analysis/attestation_performance/{index}?start_epoch,end_epoch - pub async fn get_lighthouse_analysis_attestation_performance( - &self, - start_epoch: Epoch, - end_epoch: Epoch, - target: String, - ) -> Result<Vec<AttestationPerformance>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("lighthouse") - .push("analysis") - .push("attestation_performance") - .push(&target); - - path.query_pairs_mut() - .append_pair("start_epoch", &start_epoch.to_string()) - .append_pair("end_epoch", &end_epoch.to_string()); - - self.get(path).await - } } diff --git a/common/eth2/src/lighthouse/attestation_performance.rs b/common/eth2/src/lighthouse/attestation_performance.rs deleted file mode 100644 index 5ce1d90a38..0000000000 --- a/common/eth2/src/lighthouse/attestation_performance.rs +++ /dev/null @@ -1,39 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use types::Epoch; - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationPerformanceStatistics { - pub active: bool, - pub head: bool, - pub target: bool, - pub source: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub delay: Option<u64>, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationPerformance { - pub index: u64, - pub epochs: HashMap<u64, AttestationPerformanceStatistics>, -} - -impl AttestationPerformance { - pub fn initialize(indices: Vec<u64>) -> Vec<Self> { - let mut vec = Vec::with_capacity(indices.len()); - for index in indices { - vec.push(Self { - index, - ..Default::default() - }) - } - vec - } -} - -/// Query parameters for the `/lighthouse/analysis/attestation_performance` endpoint. -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationPerformanceQuery { - pub start_epoch: Epoch, - pub end_epoch: Epoch, -} diff --git a/common/eth2/src/lighthouse/block_packing_efficiency.rs b/common/eth2/src/lighthouse/block_packing_efficiency.rs deleted file mode 100644 index 0ad6f46031..0000000000 --- a/common/eth2/src/lighthouse/block_packing_efficiency.rs +++ /dev/null @@ -1,34 +0,0 @@ -use serde::{Deserialize, Serialize}; -use types::{Epoch, Hash256, Slot}; - -type CommitteePosition = usize; -type Committee = u64; -type ValidatorIndex = u64; - -#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] -pub struct UniqueAttestation { - pub slot: Slot, - pub committee_index: Committee, - pub committee_position: CommitteePosition, -} -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct ProposerInfo { - pub validator_index: ValidatorIndex, - pub graffiti: String, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockPackingEfficiency { - pub slot: Slot, - pub block_hash: Hash256, - pub proposer_info: ProposerInfo, - pub available_attestations: usize, - pub included_attestations: usize, - pub prior_skip_slots: u64, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockPackingEfficiencyQuery { - pub start_epoch: Epoch, - pub end_epoch: Epoch, -} diff --git a/common/eth2/src/lighthouse/block_rewards.rs b/common/eth2/src/lighthouse/block_rewards.rs deleted file mode 100644 index 38070f3539..0000000000 --- a/common/eth2/src/lighthouse/block_rewards.rs +++ /dev/null @@ -1,60 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use types::{AttestationData, Hash256, Slot}; - -/// Details about the rewards paid to a block proposer for proposing a block. -/// -/// All rewards in GWei. -/// -/// Presently this only counts attestation rewards, but in future should be expanded -/// to include information on slashings and sync committee aggregates too. -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockReward { - /// Sum of all reward components. - pub total: u64, - /// Block root of the block that these rewards are for. - pub block_root: Hash256, - /// Metadata about the block, particularly reward-relevant metadata. - pub meta: BlockRewardMeta, - /// Rewards due to attestations. - pub attestation_rewards: AttestationRewards, - /// Sum of rewards due to sync committee signatures. - pub sync_committee_rewards: u64, -} - -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockRewardMeta { - pub slot: Slot, - pub parent_slot: Slot, - pub proposer_index: u64, - pub graffiti: String, -} - -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationRewards { - /// Total block reward from attestations included. - pub total: u64, - /// Total rewards from previous epoch attestations. - pub prev_epoch_total: u64, - /// Total rewards from current epoch attestations. - pub curr_epoch_total: u64, - /// Vec of attestation rewards for each attestation included. - /// - /// Each element of the vec is a map from validator index to reward. - pub per_attestation_rewards: Vec<HashMap<u64, u64>>, - /// The attestations themselves (optional). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attestations: Vec<AttestationData>, -} - -/// Query parameters for the `/lighthouse/block_rewards` endpoint. -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockRewardsQuery { - /// Lower slot limit for block rewards returned (inclusive). - pub start_slot: Slot, - /// Upper slot limit for block rewards returned (inclusive). - pub end_slot: Slot, - /// Include the full attestations themselves? - #[serde(default)] - pub include_attestations: bool, -} diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 52f3983869..546776f944 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -9,7 +9,11 @@ use crate::{ }; use bls::{PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use context_deserialize::ContextDeserialize; +#[cfg(feature = "network")] +use enr::{CombinedKey, Enr}; use mediatype::{MediaType, MediaTypeList, names}; +#[cfg(feature = "network")] +use multiaddr::Multiaddr; use reqwest::header::HeaderMap; use serde::{Deserialize, Deserializer, Serialize}; use serde_utils::quoted_u64::Quoted; @@ -33,9 +37,6 @@ pub mod beacon_response { pub use crate::beacon_response::*; } -#[cfg(feature = "lighthouse")] -use crate::lighthouse::BlockReward; - // Re-export error types from the unified error module pub use crate::error::{ErrorMessage, Failure, IndexedErrorMessage, ResponseError as Error}; @@ -559,12 +560,13 @@ pub struct ChainHeadData { pub execution_optimistic: Option<bool>, } +#[cfg(feature = "network")] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct IdentityData { pub peer_id: String, - pub enr: String, - pub p2p_addresses: Vec<String>, - pub discovery_addresses: Vec<String>, + pub enr: Enr<CombinedKey>, + pub p2p_addresses: Vec<Multiaddr>, + pub discovery_addresses: Vec<Multiaddr>, pub metadata: MetaData, } @@ -706,6 +708,15 @@ pub struct DataColumnIndicesQuery { #[serde(transparent)] pub struct ValidatorIndexData(#[serde(with = "serde_utils::quoted_u64_vec")] pub Vec<u64>); +impl<'de, T> ContextDeserialize<'de, T> for ValidatorIndexData { + fn context_deserialize<D>(deserializer: D, _context: T) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Self::deserialize(deserializer) + } +} + /// Borrowed variant of `ValidatorIndexData`, for serializing/sending. #[derive(Clone, Copy, Serialize)] #[serde(transparent)] @@ -759,6 +770,14 @@ pub enum GraffitiPolicy { AppendClientVersions, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PtcDuty { + pub pubkey: PublicKeyBytes, + #[serde(with = "serde_utils::quoted_u64")] + pub validator_index: u64, + pub slot: Slot, +} + #[derive(Clone, Deserialize)] pub struct ValidatorBlocksQuery { pub randao_reveal: SignatureBytes, @@ -1021,14 +1040,18 @@ impl SseDataColumnSidecar { pub fn from_data_column_sidecar<E: EthSpec>( data_column_sidecar: &DataColumnSidecar<E>, ) -> SseDataColumnSidecar { - let kzg_commitments = data_column_sidecar.kzg_commitments.to_vec(); + // TODO(gloas): fetch kzg_commitments from block for Gloas SSE events + let kzg_commitments: Vec<KzgCommitment> = match data_column_sidecar { + DataColumnSidecar::Fulu(dc) => dc.kzg_commitments.to_vec(), + DataColumnSidecar::Gloas(_) => vec![], + }; let versioned_hashes = kzg_commitments .iter() .map(|c| c.calculate_versioned_hash()) .collect(); SseDataColumnSidecar { block_root: data_column_sidecar.block_root(), - index: data_column_sidecar.index, + index: *data_column_sidecar.index(), slot: data_column_sidecar.slot(), kzg_commitments, versioned_hashes, @@ -1067,6 +1090,31 @@ pub struct BlockGossip { pub slot: Slot, pub block: Hash256, } +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub struct SseExecutionPayload { + pub slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_index: u64, + pub block_hash: ExecutionBlockHash, + pub block_root: Hash256, + pub execution_optimistic: bool, +} + +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub struct SseExecutionPayloadGossip { + pub slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_index: u64, + pub block_hash: ExecutionBlockHash, + pub block_root: Hash256, +} + +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub struct SseExecutionPayloadAvailable { + pub slot: Slot, + pub block_root: Hash256, +} + #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] pub struct SseChainReorg { pub slot: Slot, @@ -1131,6 +1179,8 @@ pub struct SseExtendedPayloadAttributesGeneric<T> { pub type SseExtendedPayloadAttributes = SseExtendedPayloadAttributesGeneric<SsePayloadAttributes>; pub type VersionedSsePayloadAttributes = ForkVersionedResponse<SseExtendedPayloadAttributes>; +pub type VersionedSseExecutionPayloadBid<E> = ForkVersionedResponse<SignedExecutionPayloadBid<E>>; +pub type VersionedSsePayloadAttestationMessage = ForkVersionedResponse<PayloadAttestationMessage>; impl<'de> ContextDeserialize<'de, ForkName> for SsePayloadAttributes { fn context_deserialize<D>(deserializer: D, context: ForkName) -> Result<Self, D::Error> @@ -1206,14 +1256,17 @@ pub enum EventKind<E: EthSpec> { LateHead(SseLateHead), LightClientFinalityUpdate(Box<BeaconResponse<LightClientFinalityUpdate<E>>>), LightClientOptimisticUpdate(Box<BeaconResponse<LightClientOptimisticUpdate<E>>>), - #[cfg(feature = "lighthouse")] - BlockReward(BlockReward), PayloadAttributes(VersionedSsePayloadAttributes), ProposerSlashing(Box<ProposerSlashing>), AttesterSlashing(Box<AttesterSlashing<E>>), BlsToExecutionChange(Box<SignedBlsToExecutionChange>), BlockGossip(Box<BlockGossip>), InclusionList(SseInclusionList<E>), + ExecutionPayload(SseExecutionPayload), + ExecutionPayloadGossip(SseExecutionPayloadGossip), + ExecutionPayloadAvailable(SseExecutionPayloadAvailable), + ExecutionPayloadBid(Box<VersionedSseExecutionPayloadBid<E>>), + PayloadAttestationMessage(Box<VersionedSsePayloadAttestationMessage>), } impl<E: EthSpec> EventKind<E> { @@ -1233,13 +1286,16 @@ impl<E: EthSpec> EventKind<E> { EventKind::LateHead(_) => "late_head", EventKind::LightClientFinalityUpdate(_) => "light_client_finality_update", EventKind::LightClientOptimisticUpdate(_) => "light_client_optimistic_update", - #[cfg(feature = "lighthouse")] - EventKind::BlockReward(_) => "block_reward", EventKind::ProposerSlashing(_) => "proposer_slashing", EventKind::AttesterSlashing(_) => "attester_slashing", EventKind::BlsToExecutionChange(_) => "bls_to_execution_change", EventKind::BlockGossip(_) => "block_gossip", EventKind::InclusionList(_) => "inclusion_list", + EventKind::ExecutionPayload(_) => "execution_payload", + EventKind::ExecutionPayloadGossip(_) => "execution_payload_gossip", + EventKind::ExecutionPayloadAvailable(_) => "execution_payload_available", + EventKind::ExecutionPayloadBid(_) => "execution_payload_bid", + EventKind::PayloadAttestationMessage(_) => "payload_attestation_message", } } @@ -1311,10 +1367,6 @@ impl<E: EthSpec> EventKind<E> { })?), ))) } - #[cfg(feature = "lighthouse")] - "block_reward" => Ok(EventKind::BlockReward(serde_json::from_str(data).map_err( - |e| ServerError::InvalidServerSentEvent(format!("Block Reward: {:?}", e)), - )?)), "attester_slashing" => Ok(EventKind::AttesterSlashing( serde_json::from_str(data).map_err(|e| { ServerError::InvalidServerSentEvent(format!("Attester Slashing: {:?}", e)) @@ -1338,6 +1390,40 @@ impl<E: EthSpec> EventKind<E> { ServerError::InvalidServerSentEvent(format!("Inclusion List {:?}", e)) })?, )), + "execution_payload" => Ok(EventKind::ExecutionPayload( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!("Execution Payload: {:?}", e)) + })?, + )), + "execution_payload_gossip" => Ok(EventKind::ExecutionPayloadGossip( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!( + "Execution Payload Gossip: {:?}", + e + )) + })?, + )), + "execution_payload_available" => Ok(EventKind::ExecutionPayloadAvailable( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!( + "Execution Payload Available: {:?}", + e + )) + })?, + )), + "execution_payload_bid" => Ok(EventKind::ExecutionPayloadBid(Box::new( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!("Execution Payload Bid: {:?}", e)) + })?, + ))), + "payload_attestation_message" => Ok(EventKind::PayloadAttestationMessage(Box::new( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!( + "Payload Attestation Message: {:?}", + e + )) + })?, + ))), _ => Err(ServerError::InvalidServerSentEvent( "Could not parse event tag".to_string(), )), @@ -1369,13 +1455,16 @@ pub enum EventTopic { PayloadAttributes, LightClientFinalityUpdate, LightClientOptimisticUpdate, - #[cfg(feature = "lighthouse")] - BlockReward, AttesterSlashing, ProposerSlashing, BlsToExecutionChange, BlockGossip, InclusionList, + ExecutionPayload, + ExecutionPayloadGossip, + ExecutionPayloadAvailable, + ExecutionPayloadBid, + PayloadAttestationMessage, } impl FromStr for EventTopic { @@ -1397,13 +1486,16 @@ impl FromStr for EventTopic { "late_head" => Ok(EventTopic::LateHead), "light_client_finality_update" => Ok(EventTopic::LightClientFinalityUpdate), "light_client_optimistic_update" => Ok(EventTopic::LightClientOptimisticUpdate), - #[cfg(feature = "lighthouse")] - "block_reward" => Ok(EventTopic::BlockReward), "attester_slashing" => Ok(EventTopic::AttesterSlashing), "proposer_slashing" => Ok(EventTopic::ProposerSlashing), "bls_to_execution_change" => Ok(EventTopic::BlsToExecutionChange), "block_gossip" => Ok(EventTopic::BlockGossip), "inclusion_list" => Ok(EventTopic::InclusionList), + "execution_payload" => Ok(EventTopic::ExecutionPayload), + "execution_payload_gossip" => Ok(EventTopic::ExecutionPayloadGossip), + "execution_payload_available" => Ok(EventTopic::ExecutionPayloadAvailable), + "execution_payload_bid" => Ok(EventTopic::ExecutionPayloadBid), + "payload_attestation_message" => Ok(EventTopic::PayloadAttestationMessage), _ => Err("event topic cannot be parsed.".to_string()), } } @@ -1426,13 +1518,20 @@ impl fmt::Display for EventTopic { EventTopic::LateHead => write!(f, "late_head"), EventTopic::LightClientFinalityUpdate => write!(f, "light_client_finality_update"), EventTopic::LightClientOptimisticUpdate => write!(f, "light_client_optimistic_update"), - #[cfg(feature = "lighthouse")] - EventTopic::BlockReward => write!(f, "block_reward"), EventTopic::AttesterSlashing => write!(f, "attester_slashing"), EventTopic::ProposerSlashing => write!(f, "proposer_slashing"), EventTopic::BlsToExecutionChange => write!(f, "bls_to_execution_change"), EventTopic::BlockGossip => write!(f, "block_gossip"), - EventTopic::InclusionList => write!(f, "inclusion_list",), + EventTopic::InclusionList => write!(f, "inclusion_list"), + EventTopic::ExecutionPayload => write!(f, "execution_payload"), + EventTopic::ExecutionPayloadGossip => write!(f, "execution_payload_gossip"), + EventTopic::ExecutionPayloadAvailable => { + write!(f, "execution_payload_available") + } + EventTopic::ExecutionPayloadBid => write!(f, "execution_payload_bid"), + EventTopic::PayloadAttestationMessage => { + write!(f, "payload_attestation_message") + } } } } @@ -1621,7 +1720,7 @@ pub struct BroadcastValidationQuery { } pub mod serde_status_code { - use crate::StatusCode; + use reqwest::StatusCode; use serde::{Deserialize, Serialize, de::Error}; pub fn serialize<S>(status_code: &StatusCode, ser: S) -> Result<S::Ok, S::Error> @@ -1740,7 +1839,7 @@ pub type JsonProduceBlockV3Response<E> = pub enum FullBlockContents<E: EthSpec> { /// This is a full deneb variant with block and blobs. BlockContents(BlockContents<E>), - /// This variant is for all pre-deneb full blocks. + /// This variant is for all pre-deneb full blocks or post-gloas beacon block. Block(BeaconBlock<E>), } @@ -1768,6 +1867,20 @@ pub struct ProduceBlockV3Metadata { pub consensus_block_value: Uint256, } +/// Metadata about a `produce_block_v4` response which is returned in the body & headers. +#[derive(Debug, Deserialize, Serialize)] +pub struct ProduceBlockV4Metadata { + // The consensus version is serialized & deserialized by `ForkVersionedResponse`. + #[serde( + skip_serializing, + skip_deserializing, + default = "dummy_consensus_version" + )] + pub consensus_version: ForkName, + #[serde(with = "serde_utils::u256_dec")] + pub consensus_block_value: Uint256, +} + impl<E: EthSpec> FullBlockContents<E> { pub fn new(block: BeaconBlock<E>, blob_data: Option<(KzgProofs<E>, BlobsList<E>)>) -> Self { match blob_data { @@ -1924,6 +2037,27 @@ impl TryFrom<&HeaderMap> for ProduceBlockV3Metadata { } } +impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { + type Error = String; + + fn try_from(headers: &HeaderMap) -> Result<Self, Self::Error> { + let consensus_version = parse_required_header(headers, CONSENSUS_VERSION_HEADER, |s| { + s.parse::<ForkName>() + .map_err(|e| format!("invalid {CONSENSUS_VERSION_HEADER}: {e:?}")) + })?; + let consensus_block_value = + parse_required_header(headers, CONSENSUS_BLOCK_VALUE_HEADER, |s| { + Uint256::from_str_radix(s, 10) + .map_err(|e| format!("invalid {CONSENSUS_BLOCK_VALUE_HEADER}: {e:?}")) + })?; + + Ok(ProduceBlockV4Metadata { + consensus_version, + consensus_block_value, + }) + } +} + /// A wrapper over a [`SignedBeaconBlock`] or a [`SignedBlockContents`]. #[derive(Clone, Debug, PartialEq, Encode, Serialize)] #[serde(untagged)] @@ -1971,7 +2105,7 @@ impl<E: EthSpec> PublishBlockRequest<E> { /// SSZ decode with fork variant determined by `fork_name`. pub fn from_ssz_bytes(bytes: &[u8], fork_name: ForkName) -> Result<Self, DecodeError> { - if fork_name.deneb_enabled() { + if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { let mut builder = ssz::SszDecoderBuilder::new(bytes); builder.register_anonymous_variable_length_item()?; builder.register_type::<KzgProofs<E>>()?; diff --git a/common/eth2_config/src/lib.rs b/common/eth2_config/src/lib.rs index 544138f0fa..2c06fe146a 100644 --- a/common/eth2_config/src/lib.rs +++ b/common/eth2_config/src/lib.rs @@ -33,6 +33,8 @@ const HOLESKY_GENESIS_STATE_SOURCE: GenesisStateSource = GenesisStateSource::Url checksum: "0xd750639607c337bbb192b15c27f447732267bf72d1650180a0e44c2d93a80741", genesis_validators_root: "0x9143aa7c615a7f7115e2b6aac319c03529df8242ae705fba9df39b79c59fa8b1", genesis_state_root: "0x0ea3f6f9515823b59c863454675fefcd1d8b4f2dbe454db166206a41fda060a0", + // ref: https://github.com/eth-clients/holesky/blob/main/README.md - Launch Epoch time + genesis_time: 1695902400, }; const HOODI_GENESIS_STATE_SOURCE: GenesisStateSource = GenesisStateSource::Url { @@ -44,6 +46,8 @@ const HOODI_GENESIS_STATE_SOURCE: GenesisStateSource = GenesisStateSource::Url { checksum: "0x7f42257ef69e055496c964a753bb07e54001ccd57ab467ef72d67af086bcfce7", genesis_validators_root: "0x212f13fc4df078b6cb7db228f1c8307566dcecf900867401a92023d7ba99cb5f", genesis_state_root: "0x2683ebc120f91f740c7bed4c866672d01e1ba51b4cc360297138465ee5df40f0", + // ref: https://github.com/eth-clients/hoodi/blob/main/README.md - Launch Epoch time + genesis_time: 1742213400, }; const CHIADO_GENESIS_STATE_SOURCE: GenesisStateSource = GenesisStateSource::Url { @@ -52,6 +56,8 @@ const CHIADO_GENESIS_STATE_SOURCE: GenesisStateSource = GenesisStateSource::Url checksum: "0xd4a039454c7429f1dfaa7e11e397ef3d0f50d2d5e4c0e4dc04919d153aa13af1", genesis_validators_root: "0x9d642dac73058fbf39c0ae41ab1e34e4d889043cb199851ded7095bc99eb4c1e", genesis_state_root: "0xa48419160f8f146ecaa53d12a5d6e1e6af414a328afdc56b60d5002bb472a077", + // ref: https://github.com/gnosischain/configs/blob/main/chiado/genesis.ssz + genesis_time: 1665396300, }; /// The core configuration of a Lighthouse beacon node. @@ -117,6 +123,10 @@ pub enum GenesisStateSource { /// /// The format should be 0x-prefixed ASCII bytes. genesis_state_root: &'static str, + /// The genesis time. + /// + /// The format should be u64. + genesis_time: u64, }, } diff --git a/common/eth2_interop_keypairs/Cargo.toml b/common/eth2_interop_keypairs/Cargo.toml index 309ff233e6..7eed6032c9 100644 --- a/common/eth2_interop_keypairs/Cargo.toml +++ b/common/eth2_interop_keypairs/Cargo.toml @@ -12,7 +12,7 @@ ethereum_hashing = { workspace = true } hex = { workspace = true } num-bigint = "0.4.2" serde = { workspace = true } -serde_yaml = { workspace = true } +yaml_serde = { workspace = true } [dev-dependencies] base64 = "0.13.0" diff --git a/common/eth2_interop_keypairs/src/lib.rs b/common/eth2_interop_keypairs/src/lib.rs index 0d24eb92f4..d00984a2d1 100644 --- a/common/eth2_interop_keypairs/src/lib.rs +++ b/common/eth2_interop_keypairs/src/lib.rs @@ -118,7 +118,7 @@ fn string_to_bytes(string: &str) -> Result<Vec<u8>, String> { pub fn keypairs_from_yaml_file(path: PathBuf) -> Result<Vec<Keypair>, String> { let file = File::open(path).map_err(|e| format!("Unable to open YAML key file: {}", e))?; - serde_yaml::from_reader::<_, Vec<YamlKeypair>>(file) + yaml_serde::from_reader::<_, Vec<YamlKeypair>>(file) .map_err(|e| format!("Could not parse YAML: {:?}", e))? .into_iter() .map(TryInto::try_into) diff --git a/common/eth2_network_config/Cargo.toml b/common/eth2_network_config/Cargo.toml index 416ffb1975..d2bdfea1fa 100644 --- a/common/eth2_network_config/Cargo.toml +++ b/common/eth2_network_config/Cargo.toml @@ -15,11 +15,11 @@ kzg = { workspace = true } pretty_reqwest_error = { workspace = true } reqwest = { workspace = true } sensitive_url = { workspace = true } -serde_yaml = { workspace = true } sha2 = { workspace = true } tracing = { workspace = true } types = { workspace = true } url = { workspace = true } +yaml_serde = { workspace = true } [build-dependencies] eth2_config = { workspace = true } diff --git a/common/eth2_network_config/built_in_network_configs/chiado/config.yaml b/common/eth2_network_config/built_in_network_configs/chiado/config.yaml index 6bc41113d6..e1eb022cc9 100644 --- a/common/eth2_network_config/built_in_network_configs/chiado/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/chiado/config.yaml @@ -49,7 +49,7 @@ ELECTRA_FORK_VERSION: 0x0500006f ELECTRA_FORK_EPOCH: 948224 # Thu Mar 6 2025 09:43:40 GMT+0000 # Fulu FULU_FORK_VERSION: 0x0600006f -FULU_FORK_EPOCH: 18446744073709551615 +FULU_FORK_EPOCH: 1353216 # Mon Mar 16 2026 09:33:00 UTC # Gloas GLOAS_FORK_VERSION: 0x0700006f GLOAS_FORK_EPOCH: 18446744073709551615 @@ -58,6 +58,8 @@ GLOAS_FORK_EPOCH: 18446744073709551615 # --------------------------------------------------------------- # 5 seconds SECONDS_PER_SLOT: 5 +# 5 seconds +SLOT_DURATION_MS: 5000 # 6 (estimate from xDai mainnet) SECONDS_PER_ETH1_BLOCK: 6 # 2**8 (= 256) epochs ~5.7 hours @@ -66,6 +68,18 @@ MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 SHARD_COMMITTEE_PERIOD: 256 # 2**10 (= 1024) ~1.4 hour ETH1_FOLLOW_DISTANCE: 1024 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 # Validator cycle # --------------------------------------------------------------- @@ -125,6 +139,7 @@ SUBNETS_PER_NODE: 2 ATTESTATION_SUBNET_COUNT: 64 ATTESTATION_SUBNET_EXTRA_BITS: 0 # ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS +# computed at runtime ATTESTATION_SUBNET_PREFIX_BITS: 6 # Deneb diff --git a/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml b/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml index d8286270e0..179e7b46aa 100644 --- a/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml @@ -48,8 +48,8 @@ ELECTRA_FORK_EPOCH: 1337856 # 2025-04-30T14:03:40.000Z EIP7805_FORK_VERSION: 0x06000000 EIP7805_FORK_EPOCH: 18446744073709551615 # Fulu -FULU_FORK_VERSION: 0x07000000 -FULU_FORK_EPOCH: 18446744073709551615 +FULU_FORK_VERSION: 0x06000064 +FULU_FORK_EPOCH: 1714688 # Tue Apr 14 2026 12:06:20 GMT+0000 # Gloas GLOAS_FORK_VERSION: 0x07000064 GLOAS_FORK_EPOCH: 18446744073709551615 @@ -58,6 +58,8 @@ GLOAS_FORK_EPOCH: 18446744073709551615 # --------------------------------------------------------------- # 5 seconds SECONDS_PER_SLOT: 5 +# 5 seconds +SLOT_DURATION_MS: 5000 # 6 (estimate from Gnosis Chain) SECONDS_PER_ETH1_BLOCK: 6 # 2**8 (= 256) epochs ~8 hours @@ -66,6 +68,18 @@ MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 SHARD_COMMITTEE_PERIOD: 256 # 2**10 (= 1024) ~1.4 hour ETH1_FOLLOW_DISTANCE: 1024 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 # Validator cycle # --------------------------------------------------------------- @@ -111,6 +125,8 @@ MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 ATTESTATION_SUBNET_COUNT: 64 ATTESTATION_SUBNET_EXTRA_BITS: 0 +# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS +# computed at runtime ATTESTATION_SUBNET_PREFIX_BITS: 6 ATTESTATION_SUBNET_SHUFFLING_PREFIX_BITS: 3 @@ -143,6 +159,11 @@ NUMBER_OF_CUSTODY_GROUPS: 128 DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 SAMPLES_PER_SLOT: 8 CUSTODY_REQUIREMENT: 4 +VALIDATOR_CUSTODY_REQUIREMENT: 8 +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# `2**14` (= 16384 epochs, ~15 days) +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 16384 MAX_BLOBS_PER_BLOCK_FULU: 12 # Gloas \ No newline at end of file diff --git a/common/eth2_network_config/built_in_network_configs/holesky/config.yaml b/common/eth2_network_config/built_in_network_configs/holesky/config.yaml index b1e9faea1d..fc5b53173b 100644 --- a/common/eth2_network_config/built_in_network_configs/holesky/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/holesky/config.yaml @@ -127,6 +127,7 @@ SUBNETS_PER_NODE: 2 ATTESTATION_SUBNET_COUNT: 64 ATTESTATION_SUBNET_EXTRA_BITS: 0 # ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS +# computed at runtime ATTESTATION_SUBNET_PREFIX_BITS: 6 ATTESTATION_SUBNET_SHUFFLING_PREFIX_BITS: 3 diff --git a/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml b/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml index 256957e119..947caa21f0 100644 --- a/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/hoodi/config.yaml @@ -133,6 +133,7 @@ SUBNETS_PER_NODE: 2 ATTESTATION_SUBNET_COUNT: 64 ATTESTATION_SUBNET_EXTRA_BITS: 0 # ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS +# computed at runtime ATTESTATION_SUBNET_PREFIX_BITS: 6 # Deneb diff --git a/common/eth2_network_config/built_in_network_configs/mainnet/bootstrap_nodes.yaml b/common/eth2_network_config/built_in_network_configs/mainnet/bootstrap_nodes.yaml index 70aeaac9c5..5a75d22965 100644 --- a/common/eth2_network_config/built_in_network_configs/mainnet/bootstrap_nodes.yaml +++ b/common/eth2_network_config/built_in_network_configs/mainnet/bootstrap_nodes.yaml @@ -31,4 +31,4 @@ # Lodestar team's bootnodes - enr:-IS4QPi-onjNsT5xAIAenhCGTDl4z-4UOR25Uq-3TmG4V3kwB9ljLTb_Kp1wdjHNj-H8VVLRBSSWVZo3GUe3z6k0E-IBgmlkgnY0gmlwhKB3_qGJc2VjcDI1NmsxoQMvAfgB4cJXvvXeM6WbCG86CstbSxbQBSGx31FAwVtOTYN1ZHCCIyg # 160.119.254.161 | hostafrica-southafrica -- enr:-KG4QCb8NC3gEM3I0okStV5BPX7Bg6ZXTYCzzbYyEXUPGcZtHmvQtiJH4C4F2jG7azTcb9pN3JlgpfxAnRVFzJ3-LykBgmlkgnY0gmlwhFPlR9KDaXA2kP6AAAAAAAAAAlBW__4my5iJc2VjcDI1NmsxoQLdUv9Eo9sxCt0tc_CheLOWnX59yHJtkBSOL7kpxdJ6GYN1ZHCCIyiEdWRwNoIjKA # 83.229.71.210 | kamatera-telaviv-israel \ No newline at end of file +- enr:-KG4QPUf8-g_jU-KrwzG42AGt0wWM1BTnQxgZXlvCEIfTQ5hSmptkmgmMbRkpOqv6kzb33SlhPHJp7x4rLWWiVq5lSECgmlkgnY0gmlwhFPlR9KDaXA2kCoGxcAJAAAVAAAAAAAAABCJc2VjcDI1NmsxoQLdUv9Eo9sxCt0tc_CheLOWnX59yHJtkBSOL7kpxdJ6GYN1ZHCCIyiEdWRwNoIjKA # 83.229.71.210 | kamatera-telaviv-israel diff --git a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml index 1f66d34646..1a9b8d946a 100644 --- a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml @@ -22,13 +22,13 @@ TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 # Genesis # --------------------------------------------------------------- -# `2**14` (= 16,384) +# 2**14 (= 16,384) validators MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 # Dec 1, 2020, 12pm UTC MIN_GENESIS_TIME: 1606824000 -# Mainnet initial fork version, recommend altering for testnets +# Initial fork version for mainnet GENESIS_FORK_VERSION: 0x00000000 -# 604800 seconds (7 days) +# 7 * 24 * 3,600 (= 604,800) seconds, 7 days GENESIS_DELAY: 604800 # Forking @@ -39,32 +39,38 @@ GENESIS_DELAY: 604800 # Altair ALTAIR_FORK_VERSION: 0x01000000 -ALTAIR_FORK_EPOCH: 74240 # Oct 27, 2021, 10:56:23am UTC +ALTAIR_FORK_EPOCH: 74240 # Oct 27, 2021, 10:56:23am UTC # Bellatrix BELLATRIX_FORK_VERSION: 0x02000000 -BELLATRIX_FORK_EPOCH: 144896 # Sept 6, 2022, 11:34:47am UTC +BELLATRIX_FORK_EPOCH: 144896 # Sept 6, 2022, 11:34:47am UTC # Capella CAPELLA_FORK_VERSION: 0x03000000 -CAPELLA_FORK_EPOCH: 194048 # April 12, 2023, 10:27:35pm UTC +CAPELLA_FORK_EPOCH: 194048 # April 12, 2023, 10:27:35pm UTC # Deneb DENEB_FORK_VERSION: 0x04000000 -DENEB_FORK_EPOCH: 269568 # March 13, 2024, 01:55:35pm UTC +DENEB_FORK_EPOCH: 269568 # March 13, 2024, 01:55:35pm UTC # Electra ELECTRA_FORK_VERSION: 0x05000000 -ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC +ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC # Fulu FULU_FORK_VERSION: 0x06000000 -FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC +FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC # Eip7805 EIP7805_FORK_VERSION: 0x07000000 EIP7805_FORK_EPOCH: 18446744073709551615 # Gloas GLOAS_FORK_VERSION: 0x08000000 GLOAS_FORK_EPOCH: 18446744073709551615 +# Heze +HEZE_FORK_VERSION: 0x08000000 +HEZE_FORK_EPOCH: 18446744073709551615 +# EIP7928 +EIP7928_FORK_VERSION: 0xe7928000 # temporary stub +EIP7928_FORK_EPOCH: 18446744073709551615 # Time parameters # --------------------------------------------------------------- -# 12 seconds (*deprecated*) +# 12 seconds SECONDS_PER_SLOT: 12 # 12000 milliseconds SLOT_DURATION_MS: 12000 @@ -89,6 +95,28 @@ SYNC_MESSAGE_DUE_BPS: 3333 # 6667 basis points, ~67% of SLOT_DURATION_MS CONTRIBUTION_DUE_BPS: 6667 +# Gloas +# 2**6 (= 64) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + +# Heze +# 7500 basis points, 75% of SLOT_DURATION_MS +VIEW_FREEZE_CUTOFF_BPS: 7500 +# 6667 basis points, ~67% of SLOT_DURATION_MS +INCLUSION_LIST_SUBMISSION_DUE_BPS: 6667 +# 9167 basis points, ~92% of SLOT_DURATION_MS +PROPOSER_INCLUSION_LIST_CUTOFF_BPS: 9167 + # Validator cycle # --------------------------------------------------------------- # 2**2 (= 4) @@ -138,12 +166,6 @@ MAX_PAYLOAD_SIZE: 10485760 MAX_REQUEST_BLOCKS: 1024 # 2**8 (= 256) epochs EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 -# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs -MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 -# 5s -TTFB_TIMEOUT: 5 -# 10s -RESP_TIMEOUT: 10 # 2**5 (= 32) slots ATTESTATION_PROPAGATION_SLOT_RANGE: 32 # 500ms @@ -156,9 +178,6 @@ SUBNETS_PER_NODE: 2 ATTESTATION_SUBNET_COUNT: 64 # 0 bits ATTESTATION_SUBNET_EXTRA_BITS: 0 -# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits -ATTESTATION_SUBNET_PREFIX_BITS: 6 -ATTESTATION_SUBNET_SHUFFLING_PREFIX_BITS: 3 # Deneb # 2**7 (= 128) blocks @@ -169,24 +188,18 @@ MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 BLOB_SIDECAR_SUBNET_COUNT: 6 # 6 blobs MAX_BLOBS_PER_BLOCK: 6 -# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars -MAX_REQUEST_BLOB_SIDECARS: 768 # Electra # 9 subnets BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 # 9 blobs MAX_BLOBS_PER_BLOCK_ELECTRA: 9 -# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars -MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 # Fulu # 2**7 (= 128) groups NUMBER_OF_CUSTODY_GROUPS: 128 # 2**7 (= 128) subnets DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 -# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars -MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 # 2**3 (= 8) samples SAMPLES_PER_SLOT: 8 # 2**2 (= 4) sidecars @@ -198,6 +211,17 @@ BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 # 2**12 (= 4,096) epochs MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + +# Heze +# 2**4 (= 16) inclusion lists +MAX_REQUEST_INCLUSION_LIST: 16 +# 2**13 (= 8,192) bytes +MAX_BYTES_PER_INCLUSION_LIST: 8192 + + # Blob Scheduling # --------------------------------------------------------------- @@ -212,4 +236,3 @@ ATTESTATION_DEADLINE: 4 PROPOSER_INCLUSION_LIST_CUT_OFF: 11 VIEW_FREEZE_DEADLINE: 9 -# Gloas diff --git a/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml b/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml index b1a01933d7..6a655d8e84 100644 --- a/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/sepolia/config.yaml @@ -133,6 +133,7 @@ SUBNETS_PER_NODE: 2 ATTESTATION_SUBNET_COUNT: 64 ATTESTATION_SUBNET_EXTRA_BITS: 0 # ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS +# computed at runtime ATTESTATION_SUBNET_PREFIX_BITS: 6 ATTESTATION_SUBNET_SHUFFLING_PREFIX_BITS: 3 diff --git a/common/eth2_network_config/src/lib.rs b/common/eth2_network_config/src/lib.rs index 16ee45e524..408ce6135d 100644 --- a/common/eth2_network_config/src/lib.rs +++ b/common/eth2_network_config/src/lib.rs @@ -101,14 +101,14 @@ impl Eth2NetworkConfig { /// Instantiates `Self` from a `HardcodedNet`. fn from_hardcoded_net(net: &HardcodedNet) -> Result<Self, String> { - let config: Config = serde_yaml::from_reader(net.config) + let config: Config = yaml_serde::from_reader(net.config) .map_err(|e| format!("Unable to parse yaml config: {:?}", e))?; let kzg_trusted_setup = get_trusted_setup(); Ok(Self { - deposit_contract_deploy_block: serde_yaml::from_reader(net.deploy_block) + deposit_contract_deploy_block: yaml_serde::from_reader(net.deploy_block) .map_err(|e| format!("Unable to parse deploy block: {:?}", e))?, boot_enr: Some( - serde_yaml::from_reader(net.boot_enr) + yaml_serde::from_reader(net.boot_enr) .map_err(|e| format!("Unable to parse boot enr: {:?}", e))?, ), genesis_state_source: net.genesis_state_source, @@ -133,6 +133,16 @@ impl Eth2NetworkConfig { self.genesis_state_source != GenesisStateSource::Unknown } + /// The `genesis_time` of the genesis state. + pub fn genesis_time<E: EthSpec>(&self) -> Result<Option<u64>, String> { + if let GenesisStateSource::Url { genesis_time, .. } = self.genesis_state_source { + Ok(Some(genesis_time)) + } else { + self.get_genesis_state_from_bytes::<E>() + .map(|state| Some(state.genesis_time())) + } + } + /// The `genesis_validators_root` of the genesis state. pub fn genesis_validators_root<E: EthSpec>(&self) -> Result<Option<Hash256>, String> { if let GenesisStateSource::Url { @@ -276,7 +286,7 @@ impl Eth2NetworkConfig { File::create(base_dir.join($file)) .map_err(|e| format!("Unable to create {}: {:?}", $file, e)) .and_then(|mut file| { - let yaml = serde_yaml::to_string(&$variable) + let yaml = yaml_serde::to_string(&$variable) .map_err(|e| format!("Unable to YAML encode {}: {:?}", $file, e))?; // Remove the doc header from the YAML file. @@ -324,7 +334,7 @@ impl Eth2NetworkConfig { File::open(base_dir.join($file)) .map_err(|e| format!("Unable to open {}: {:?}", $file, e)) .and_then(|file| { - serde_yaml::from_reader(file) + yaml_serde::from_reader(file) .map_err(|e| format!("Unable to parse {}: {:?}", $file, e)) })? }; diff --git a/common/health_metrics/src/observe.rs b/common/health_metrics/src/observe.rs index 81bb8e6f7e..5bc3770301 100644 --- a/common/health_metrics/src/observe.rs +++ b/common/health_metrics/src/observe.rs @@ -121,7 +121,7 @@ impl Observe for ProcessHealth { pid_mem_shared_memory_size: process_mem.shared(), pid_process_seconds_total: process_times.busy().as_secs() + process_times.children_system().as_secs() - + process_times.children_system().as_secs(), + + process_times.children_user().as_secs(), }) } } diff --git a/common/logging/Cargo.toml b/common/logging/Cargo.toml index 41c82dbd61..1606b8ceb4 100644 --- a/common/logging/Cargo.toml +++ b/common/logging/Cargo.toml @@ -5,7 +5,8 @@ authors = ["blacktemplar <blacktemplar@a1.net>"] edition = { workspace = true } [features] -test_logger = [] # Print log output to stderr when running tests instead of dropping it +# Print log output to stderr when running tests instead of dropping it. +test_logger = [] [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } @@ -13,7 +14,7 @@ logroller = { workspace = true } metrics = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = [ "time" ] } +tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-core = { workspace = true } diff --git a/common/malloc_utils/Cargo.toml b/common/malloc_utils/Cargo.toml index 1052128852..e90490bf09 100644 --- a/common/malloc_utils/Cargo.toml +++ b/common/malloc_utils/Cargo.toml @@ -35,7 +35,4 @@ tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats"] } # Jemalloc's background_threads feature requires Linux (pthreads). [target.'cfg(target_os = "linux")'.dependencies] -tikv-jemallocator = { version = "0.6.0", optional = true, features = [ - "stats", - "background_threads", -] } +tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats", "background_threads"] } diff --git a/common/slot_clock/src/lib.rs b/common/slot_clock/src/lib.rs index e51bc3f647..757d0164ca 100644 --- a/common/slot_clock/src/lib.rs +++ b/common/slot_clock/src/lib.rs @@ -2,14 +2,13 @@ mod manual_slot_clock; mod metrics; mod system_time_slot_clock; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub use crate::manual_slot_clock::ManualSlotClock as TestingSlotClock; pub use crate::manual_slot_clock::ManualSlotClock; pub use crate::system_time_slot_clock::SystemTimeSlotClock; pub use metrics::scrape_for_metrics; pub use types::Slot; -use types::consts::bellatrix::INTERVALS_PER_SLOT; /// A clock that reports the current slot. /// @@ -77,30 +76,6 @@ pub trait SlotClock: Send + Sync + Sized + Clone { .or_else(|| Some(self.genesis_slot())) } - /// Returns the delay between the start of the slot and when unaggregated attestations should be - /// produced. - fn unagg_attestation_production_delay(&self) -> Duration { - self.slot_duration() / INTERVALS_PER_SLOT as u32 - } - - /// Returns the delay between the start of the slot and when sync committee messages should be - /// produced. - fn sync_committee_message_production_delay(&self) -> Duration { - self.slot_duration() / INTERVALS_PER_SLOT as u32 - } - - /// Returns the delay between the start of the slot and when aggregated attestations should be - /// produced. - fn agg_attestation_production_delay(&self) -> Duration { - self.slot_duration() * 2 / INTERVALS_PER_SLOT as u32 - } - - /// Returns the delay between the start of the slot and when partially aggregated `SyncCommitteeContribution` should be - /// produced. - fn sync_committee_contribution_production_delay(&self) -> Duration { - self.slot_duration() * 2 / INTERVALS_PER_SLOT as u32 - } - /// Returns the `Duration` since the start of the current `Slot` at seconds precision. Useful in determining whether to apply proposer boosts. fn seconds_from_current_slot_start(&self) -> Option<Duration> { self.now_duration() @@ -134,13 +109,14 @@ pub trait SlotClock: Send + Sync + Sized + Clone { slot_clock.set_current_time(freeze_at); slot_clock } - - /// Returns the delay between the start of the slot and when a request for block components - /// missed over gossip in the current slot should be made via RPC. - /// - /// Currently set equal to 1/2 of the `unagg_attestation_production_delay`, but this may be - /// changed in the future. - fn single_lookup_delay(&self) -> Duration { - self.unagg_attestation_production_delay() / 2 - } +} + +/// Returns the current system time as a duration since the UNIX epoch. +/// +/// This is a convenience function for recording timestamps when `SlotClock` is not available. +/// Prefer `SlotClock::now_duration` if available. +pub fn timestamp_now() -> Duration { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() } diff --git a/common/task_executor/src/lib.rs b/common/task_executor/src/lib.rs index d3d862f96c..07716fa2e7 100644 --- a/common/task_executor/src/lib.rs +++ b/common/task_executor/src/lib.rs @@ -6,7 +6,7 @@ use futures::channel::mpsc::Sender; use futures::prelude::*; use std::sync::{Arc, Weak}; use tokio::runtime::{Handle, Runtime}; -use tracing::debug; +use tracing::{Span, debug}; use crate::rayon_pool_provider::RayonPoolProvider; pub use crate::rayon_pool_provider::RayonPoolType; @@ -225,9 +225,11 @@ impl TaskExecutor { F: FnOnce() + Send + 'static, { let thread_pool = self.rayon_pool_provider.get_thread_pool(rayon_pool_type); + let span = Span::current(); self.spawn_blocking( move || { thread_pool.install(|| { + let _guard = span.enter(); task(); }); }, @@ -247,8 +249,10 @@ impl TaskExecutor { { let thread_pool = self.rayon_pool_provider.get_thread_pool(rayon_pool_type); let (tx, rx) = tokio::sync::oneshot::channel(); + let span = Span::current(); thread_pool.spawn(move || { + let _guard = span.enter(); let result = task(); let _ = tx.send(result); }); @@ -320,8 +324,12 @@ impl TaskExecutor { let timer = metrics::start_timer_vec(&metrics::BLOCKING_TASKS_HISTOGRAM, &[name]); metrics::inc_gauge_vec(&metrics::BLOCKING_TASKS_COUNT, &[name]); + let span = Span::current(); let join_handle = if let Some(handle) = self.handle() { - handle.spawn_blocking(task) + handle.spawn_blocking(move || { + let _guard = span.enter(); + task() + }) } else { debug!("Couldn't spawn task. Runtime shutting down"); return None; diff --git a/common/task_executor/src/rayon_pool_provider.rs b/common/task_executor/src/rayon_pool_provider.rs index 8e12f7eaa4..ea8c988fb7 100644 --- a/common/task_executor/src/rayon_pool_provider.rs +++ b/common/task_executor/src/rayon_pool_provider.rs @@ -15,7 +15,7 @@ pub struct RayonPoolProvider { /// By default ~25% of CPUs or a minimum of 1 thread. low_priority_thread_pool: Arc<ThreadPool>, /// Larger rayon thread pool for high-priority, compute-intensive tasks. - /// By default ~80% of CPUs or a minimum of 1 thread. Citical/highest + /// By default ~80% of CPUs or a minimum of 1 thread. Critical/highest /// priority tasks should use the global pool instead. high_priority_thread_pool: Arc<ThreadPool>, } diff --git a/common/tracing_samplers/Cargo.toml b/common/tracing_samplers/Cargo.toml new file mode 100644 index 0000000000..76afc10ee7 --- /dev/null +++ b/common/tracing_samplers/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "tracing_samplers" +version = "0.1.0" +edition = { workspace = true } + +[dependencies] +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } diff --git a/common/tracing_samplers/src/lib.rs b/common/tracing_samplers/src/lib.rs new file mode 100644 index 0000000000..e34518ac4a --- /dev/null +++ b/common/tracing_samplers/src/lib.rs @@ -0,0 +1,78 @@ +//! OpenTelemetry samplers for filtering traces. + +use opentelemetry::Context; +use opentelemetry::trace::{Link, SamplingDecision, SamplingResult, SpanKind, TraceState}; +use opentelemetry_sdk::trace::ShouldSample; + +/// A sampler that only samples spans whose names start with a given prefix. +/// +/// This sampler is designed to be used with `Sampler::ParentBased`, which will: +/// - Use this sampler's decision for root spans (no parent) +/// - Automatically inherit the parent's sampling decision for child spans +/// +/// This ensures that only traces starting from known instrumented code paths are exported, +/// reducing noise from partially instrumented code paths. +#[derive(Debug, Clone)] +pub struct PrefixBasedSampler { + prefix: &'static str, +} + +impl PrefixBasedSampler { + pub fn new(prefix: &'static str) -> Self { + Self { prefix } + } +} + +impl ShouldSample for PrefixBasedSampler { + fn should_sample( + &self, + _parent_context: Option<&Context>, + _trace_id: opentelemetry::trace::TraceId, + name: &str, + _span_kind: &SpanKind, + _attributes: &[opentelemetry::KeyValue], + _links: &[Link], + ) -> SamplingResult { + if name.starts_with(self.prefix) { + SamplingResult { + decision: SamplingDecision::RecordAndSample, + attributes: Vec::new(), + trace_state: TraceState::default(), + } + } else { + SamplingResult { + decision: SamplingDecision::Drop, + attributes: Vec::new(), + trace_state: TraceState::default(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::trace::TraceId; + + #[test] + fn prefix_based_sampler_filters_by_prefix() { + let sampler = PrefixBasedSampler::new("test_"); + let trace_id = TraceId::from_hex("0123456789abcdef0123456789abcdef").unwrap(); + + // Spans with prefix should be sampled + let result = sampler.should_sample( + None, + trace_id, + "test_my_span", + &SpanKind::Internal, + &[], + &[], + ); + assert!(matches!(result.decision, SamplingDecision::RecordAndSample)); + + // Spans without prefix should be dropped + let result = + sampler.should_sample(None, trace_id, "other_span", &SpanKind::Internal, &[], &[]); + assert!(matches!(result.decision, SamplingDecision::Drop)); + } +} diff --git a/common/warp_utils/Cargo.toml b/common/warp_utils/Cargo.toml index 32a540a69d..80bc247cbf 100644 --- a/common/warp_utils/Cargo.toml +++ b/common/warp_utils/Cargo.toml @@ -9,6 +9,7 @@ edition = { workspace = true } bytes = { workspace = true } eth2 = { workspace = true } headers = "0.3.2" +reqwest = { workspace = true } safe_arith = { workspace = true } serde = { workspace = true } serde_array_query = "0.1.0" diff --git a/common/warp_utils/src/status_code.rs b/common/warp_utils/src/status_code.rs index 1b05297359..a654b6d2c5 100644 --- a/common/warp_utils/src/status_code.rs +++ b/common/warp_utils/src/status_code.rs @@ -1,4 +1,4 @@ -use eth2::StatusCode; +use reqwest::StatusCode; use warp::Rejection; /// Convert from a "new" `http::StatusCode` to a `warp` compatible one. diff --git a/consensus/fork_choice/Cargo.toml b/consensus/fork_choice/Cargo.toml index a07aa38aa5..df47a5c9d1 100644 --- a/consensus/fork_choice/Cargo.toml +++ b/consensus/fork_choice/Cargo.toml @@ -19,5 +19,6 @@ types = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } +bls = { workspace = true } store = { workspace = true } tokio = { workspace = true } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 3fb049633f..1642bfb0cf 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -3,10 +3,9 @@ use crate::{ForkChoiceStore, InvalidationOperation}; use fixed_bytes::FixedBytesExtended; use logging::crit; use proto_array::{ - Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, - ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, + Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, LatestMessage, + PayloadStatus, ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; -use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use state_processing::{ per_block_processing::errors::AttesterSlashingValidationError, per_epoch_processing, @@ -20,13 +19,14 @@ use tracing::{debug, instrument, warn}; use types::{ AbstractExecPayload, AttestationShufflingId, AttesterSlashingRef, BeaconBlockRef, BeaconState, BeaconStateError, ChainSpec, Checkpoint, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, - Hash256, IndexedAttestationRef, RelativeEpoch, SignedBeaconBlock, Slot, - consts::bellatrix::INTERVALS_PER_SLOT, + Hash256, IndexedAttestationRef, IndexedPayloadAttestation, RelativeEpoch, SignedBeaconBlock, + Slot, }; #[derive(Debug)] pub enum Error<T> { InvalidAttestation(InvalidAttestation), + InvalidPayloadAttestation(InvalidPayloadAttestation), InvalidAttesterSlashing(AttesterSlashingValidationError), InvalidBlock(InvalidBlock), ProtoArrayStringError(String), @@ -77,6 +77,8 @@ pub enum Error<T> { }, UnrealizedVoteProcessing(state_processing::EpochProcessingError), ValidatorStatuses(BeaconStateError), + ChainSpecError(String), + DoesNotDescendFromFinalizedCheckpoint, } impl<T> From<InvalidAttestation> for Error<T> { @@ -85,6 +87,12 @@ impl<T> From<InvalidAttestation> for Error<T> { } } +impl<T> From<InvalidPayloadAttestation> for Error<T> { + fn from(e: InvalidPayloadAttestation) -> Self { + Error::InvalidPayloadAttestation(e) + } +} + impl<T> From<AttesterSlashingValidationError> for Error<T> { fn from(e: AttesterSlashingValidationError) -> Self { Error::InvalidAttesterSlashing(e) @@ -170,6 +178,33 @@ pub enum InvalidAttestation { /// The attestation is attesting to a state that is later than itself. (Viz., attesting to the /// future). AttestsToFutureBlock { block: Slot, attestation: Slot }, + /// Post-Gloas: attestation index must be 0 or 1. + InvalidAttestationIndex { index: u64 }, + /// A same-slot attestation has a non-zero index, which is invalid post-Gloas. + InvalidSameSlotAttestationIndex { slot: Slot }, + /// Post-Gloas: attestation with index == 1 (payload_present) requires the block's + /// payload to have been received (`root in store.payload_states`). + PayloadNotReceived { beacon_block_root: Hash256 }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum InvalidPayloadAttestation { + /// The payload attestation's attesting indices were empty. + EmptyAggregationBitfield, + /// The `payload_attestation.data.beacon_block_root` block is unknown. + UnknownHeadBlock { beacon_block_root: Hash256 }, + /// The payload attestation is attesting to a block that is later than itself. + AttestsToFutureBlock { block: Slot, attestation: Slot }, + /// A gossip payload attestation must be for the current slot. + PayloadAttestationNotCurrentSlot { + attestation_slot: Slot, + current_slot: Slot, + }, + /// One or more payload attesters are not part of the PTC. + PayloadAttestationAttestersNotInPtc { + attesting_indices_len: usize, + attesting_indices_in_ptc: usize, + }, } impl<T> From<String> for Error<T> { @@ -241,6 +276,17 @@ pub struct QueuedAttestation { attesting_indices: Vec<u64>, block_root: Hash256, target_epoch: Epoch, + /// Per Gloas spec: `payload_present = attestation.data.index == 1`. + payload_present: bool, +} + +/// Legacy queued attestation without payload_present (pre-Gloas, schema V28). +#[derive(Clone, PartialEq, Encode, Decode)] +pub struct QueuedAttestationV28 { + slot: Slot, + attesting_indices: Vec<u64>, + block_root: Hash256, + target_epoch: Epoch, } impl<'a, E: EthSpec> From<IndexedAttestationRef<'a, E>> for QueuedAttestation { @@ -250,6 +296,7 @@ impl<'a, E: EthSpec> From<IndexedAttestationRef<'a, E>> for QueuedAttestation { attesting_indices: a.attesting_indices_to_vec(), block_root: a.data().beacon_block_root, target_epoch: a.data().target.epoch, + payload_present: a.data().index == 1, } } } @@ -367,21 +414,32 @@ where AttestationShufflingId::new(anchor_block_root, anchor_state, RelativeEpoch::Next) .map_err(Error::BeaconStateError)?; - let execution_status = anchor_block.message().execution_payload().map_or_else( - // If the block doesn't have an execution payload then it can't have - // execution enabled. - |_| ExecutionStatus::irrelevant(), - |execution_payload| { + let (execution_status, execution_payload_parent_hash, execution_payload_block_hash) = + if let Ok(signed_bid) = anchor_block.message().body().signed_execution_payload_bid() { + // Gloas: execution status is irrelevant post-Gloas; payload validation + // is decoupled from beacon blocks. + ( + ExecutionStatus::irrelevant(), + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else if let Ok(execution_payload) = anchor_block.message().execution_payload() { + // Pre-Gloas forks: do not set payload hashes, they are only used post-Gloas. if execution_payload.is_default_with_empty_roots() { - // A default payload does not have execution enabled. - ExecutionStatus::irrelevant() + (ExecutionStatus::irrelevant(), None, None) } else { - // Assume that this payload is valid, since the anchor should be a trusted block and - // state. - ExecutionStatus::Valid(execution_payload.block_hash()) + // Assume that this payload is valid, since the anchor should be a + // trusted block and state. + ( + ExecutionStatus::Valid(execution_payload.block_hash()), + None, + None, + ) } - }, - ); + } else { + // Pre-merge: no execution payload at all. + (ExecutionStatus::irrelevant(), None, None) + }; // If the current slot is not provided, use the value that was last provided to the store. let current_slot = current_slot.unwrap_or_else(|| fc_store.get_current_slot()); @@ -396,6 +454,10 @@ where next_epoch_shuffling_id, fc_store.unsatisfied_inclusion_list_blocks().clone(), execution_status, + execution_payload_parent_hash, + execution_payload_block_hash, + anchor_block.message().proposer_index(), + spec, )?; let mut fork_choice = Self { @@ -481,7 +543,7 @@ where &mut self, system_time_current_slot: Slot, spec: &ChainSpec, - ) -> Result<Hash256, Error<T::Error>> { + ) -> Result<(Hash256, PayloadStatus), Error<T::Error>> { // Provide the slot (as per the system clock) to the `fc_store` and then return its view of // the current slot. The `fc_store` will ensure that the `current_slot` is never // decreasing, a property which we must maintain. @@ -489,7 +551,7 @@ where let store = &mut self.fc_store; - let head_root = self.proto_array.find_head::<E>( + let (head_root, head_payload_status) = self.proto_array.find_head::<E>( *store.justified_checkpoint(), *store.finalized_checkpoint(), store.justified_balances(), @@ -500,9 +562,22 @@ where )?; // Cache some values for the next forkchoiceUpdate call to the execution layer. - let head_hash = self - .get_block(&head_root) - .and_then(|b| b.execution_status.block_hash()); + // For Gloas blocks, `execution_status` is Irrelevant (no embedded payload). + // If the payload envelope was received (Full), use the bid's block_hash as the + // execution chain head. Otherwise fall back to the parent hash (Pending) or None. + // TODO(gloas): this is a bit messy, and we probably need a similar treatment for + // justified/finalized + // Can fix as part of: https://github.com/sigp/lighthouse/issues/8957 + let head_hash = self.get_block(&head_root).and_then(|b| { + b.execution_status + .block_hash() + .or(match head_payload_status { + PayloadStatus::Full => b.execution_payload_block_hash, + PayloadStatus::Pending | PayloadStatus::Empty => { + b.execution_payload_parent_hash + } + }) + }); let justified_root = self.justified_checkpoint().root; let finalized_root = self.finalized_checkpoint().root; let justified_hash = self @@ -518,7 +593,7 @@ where finalized_hash, }; - Ok(head_root) + Ok((head_root, head_payload_status)) } /// Get the block to build on as proposer, taking into account proposer re-orgs. @@ -613,6 +688,20 @@ where } } + /// Mark a Gloas payload envelope as valid and received. + /// + /// This must only be called for valid Gloas payloads. + pub fn on_valid_payload_envelope_received( + &mut self, + block_root: Hash256, + ) -> Result<(), Error<T::Error>> { + self.proto_array + .on_valid_payload_envelope_received(block_root) + .map_err(Error::FailedToProcessValidExecutionPayload) + } + + /// Pre-Gloas only. + /// /// See `ProtoArrayForkChoice::process_execution_payload_validation` for documentation. pub fn on_valid_execution_payload( &mut self, @@ -623,6 +712,8 @@ where .map_err(Error::FailedToProcessValidExecutionPayload) } + /// Pre-Gloas only. + /// /// See `ProtoArrayForkChoice::process_execution_payload_invalidation` for documentation. pub fn on_invalid_execution_payload( &mut self, @@ -672,6 +763,7 @@ where block_delay: Duration, state: &BeaconState<E>, payload_verification_status: PayloadVerificationStatus, + canonical_head_proposer_index: u64, spec: &ChainSpec, ) -> Result<(), Error<T::Error>> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_BLOCK_TIMES); @@ -734,12 +826,20 @@ where })); } - // Add proposer score boost if the block is timely. - let is_before_attesting_interval = - block_delay < Duration::from_secs(spec.seconds_per_slot / INTERVALS_PER_SLOT); + let attestation_threshold = spec.get_attestation_due::<E>(block.slot()); + + // Add proposer score boost if the block is the first timely block for this slot and its + // proposer matches the expected proposer on the canonical chain (per spec + // `update_proposer_boost_root`, introduced in v1.7.0-alpha.5). + let is_before_attesting_interval = block_delay < attestation_threshold; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); - if current_slot == block.slot() && is_before_attesting_interval && is_first_block { + let is_canonical_proposer = block.proposer_index() == canonical_head_proposer_index; + if current_slot == block.slot() + && is_before_attesting_interval + && is_first_block + && is_canonical_proposer + { self.fc_store.set_proposer_boost_root(block_root); } @@ -888,6 +988,16 @@ where ExecutionStatus::irrelevant() }; + let (execution_payload_parent_hash, execution_payload_block_hash) = + if let Ok(signed_bid) = block.body().signed_execution_payload_bid() { + ( + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else { + (None, None) + }; + // This does not apply a vote to the block, it just makes fork choice aware of the block so // it can still be identified as the head even if it doesn't have any votes. self.proto_array.process_block::<E>( @@ -914,10 +1024,13 @@ where execution_status, unrealized_justified_checkpoint: Some(unrealized_justified_checkpoint), unrealized_finalized_checkpoint: Some(unrealized_finalized_checkpoint), + execution_payload_parent_hash, + execution_payload_block_hash, + proposer_index: Some(block.proposer_index()), }, current_slot, - self.justified_checkpoint(), - self.finalized_checkpoint(), + spec, + block_delay, )?; Ok(()) @@ -986,6 +1099,7 @@ where &self, indexed_attestation: IndexedAttestationRef<E>, is_from_block: AttestationFromBlock, + spec: &ChainSpec, ) -> Result<(), InvalidAttestation> { // There is no point in processing an attestation with an empty bitfield. Reject // it immediately. @@ -1058,6 +1172,89 @@ where }); } + if spec + .fork_name_at_slot::<E>(indexed_attestation.data().slot) + .gloas_enabled() + { + let index = indexed_attestation.data().index; + + // Post-Gloas: attestation index must be 0 or 1. + if index > 1 { + return Err(InvalidAttestation::InvalidAttestationIndex { index }); + } + + // Same-slot attestations must have index == 0. + if indexed_attestation.data().slot == block.slot && index != 0 { + return Err(InvalidAttestation::InvalidSameSlotAttestationIndex { + slot: block.slot, + }); + } + + // index == 1 (payload_present) requires the block's payload to have been received. + // TODO(gloas): could optimise by adding `payload_received` to `Block` + if index == 1 + && !self + .proto_array + .is_payload_received(&indexed_attestation.data().beacon_block_root) + { + return Err(InvalidAttestation::PayloadNotReceived { + beacon_block_root: indexed_attestation.data().beacon_block_root, + }); + } + } + + Ok(()) + } + + /// Validates a payload attestation for application to fork choice. + fn validate_on_payload_attestation( + &self, + indexed_payload_attestation: &IndexedPayloadAttestation<E>, + is_from_block: AttestationFromBlock, + ) -> Result<(), InvalidPayloadAttestation> { + // This check is from `is_valid_indexed_payload_attestation`, but we do it immediately to + // avoid wasting time on junk attestations. + if indexed_payload_attestation.attesting_indices.is_empty() { + return Err(InvalidPayloadAttestation::EmptyAggregationBitfield); + } + + // PTC attestation must be for a known block. If block is unknown, delay consideration until + // the block is found (responsibility of caller). + let block = self + .proto_array + .get_block(&indexed_payload_attestation.data.beacon_block_root) + .ok_or(InvalidPayloadAttestation::UnknownHeadBlock { + beacon_block_root: indexed_payload_attestation.data.beacon_block_root, + })?; + + // Not strictly part of the spec, but payload attestations to future slots are MORE INVALID + // than payload attestations to blocks at previous slots. + if block.slot > indexed_payload_attestation.data.slot { + return Err(InvalidPayloadAttestation::AttestsToFutureBlock { + block: block.slot, + attestation: indexed_payload_attestation.data.slot, + }); + } + + // PTC votes can only change the vote for their assigned beacon block, return early otherwise + if block.slot != indexed_payload_attestation.data.slot { + return Ok(()); + } + + // Gossip payload attestations must be for the current slot. + // NOTE: signature is assumed to have been verified by caller. + // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md + if matches!(is_from_block, AttestationFromBlock::False) + && indexed_payload_attestation.data.slot != self.fc_store.get_current_slot() + { + return Err( + InvalidPayloadAttestation::PayloadAttestationNotCurrentSlot { + attestation_slot: indexed_payload_attestation.data.slot, + current_slot: self.fc_store.get_current_slot(), + }, + ); + } + Ok(()) } @@ -1083,6 +1280,7 @@ where system_time_current_slot: Slot, attestation: IndexedAttestationRef<E>, is_from_block: AttestationFromBlock, + spec: &ChainSpec, ) -> Result<(), Error<T::Error>> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_ATTESTATION_TIMES); @@ -1105,14 +1303,21 @@ where return Ok(()); } - self.validate_on_attestation(attestation, is_from_block)?; + self.validate_on_attestation(attestation, is_from_block, spec)?; + + // Per Gloas spec: `payload_present = attestation.data.index == 1`. + let payload_present = spec + .fork_name_at_slot::<E>(attestation.data().slot) + .gloas_enabled() + && attestation.data().index == 1; if attestation.data().slot < self.fc_store.get_current_slot() { for validator_index in attestation.attesting_indices_iter() { self.proto_array.process_attestation( *validator_index as usize, attestation.data().beacon_block_root, - attestation.data().target.epoch, + attestation.data().slot, + payload_present, )?; } } else { @@ -1129,6 +1334,59 @@ where Ok(()) } + /// Register a payload attestation with the fork choice DAG. + /// + /// `ptc` is the PTC committee for the attestation's slot: a list of validator indices + /// ordered by committee position. Each attesting validator index is resolved to its + /// position within `ptc` (its `ptc_index`) before being applied to the proto-array. + pub fn on_payload_attestation( + &mut self, + system_time_current_slot: Slot, + attestation: &IndexedPayloadAttestation<E>, + is_from_block: AttestationFromBlock, + ptc: &[usize], + ) -> Result<(), Error<T::Error>> { + self.update_time(system_time_current_slot)?; + + if attestation.data.beacon_block_root.is_zero() { + return Ok(()); + } + + // TODO(gloas): Should ignore wrong-slot payload attestations at the caller, they could + // have been processed at the correct slot when received on gossip, but then have the + // wrong-slot by the time they make it to here (TOCTOU). + self.validate_on_payload_attestation(attestation, is_from_block)?; + + // Resolve validator indices to PTC committee positions. + let ptc_indices: Vec<usize> = attestation + .attesting_indices + .iter() + .filter_map(|validator_index| ptc.iter().position(|&p| p == *validator_index as usize)) + .collect(); + + // Check that all the attesters are in the PTC + if ptc_indices.len() != attestation.attesting_indices.len() { + return Err( + InvalidPayloadAttestation::PayloadAttestationAttestersNotInPtc { + attesting_indices_len: attestation.attesting_indices.len(), + attesting_indices_in_ptc: ptc_indices.len(), + } + .into(), + ); + } + + for &ptc_index in &ptc_indices { + self.proto_array.process_payload_attestation( + attestation.data.beacon_block_root, + ptc_index, + attestation.data.payload_present, + attestation.data.blob_data_available, + )?; + } + + Ok(()) + } + /// Apply an attester slashing to fork choice. /// /// We assume that the attester slashing provided to this function has already been verified. @@ -1235,7 +1493,8 @@ where self.proto_array.process_attestation( *validator_index as usize, attestation.block_root, - attestation.target_epoch, + attestation.slot, + attestation.payload_present, )?; } } @@ -1258,6 +1517,14 @@ where } } + /// Returns whether the proposer should extend the execution payload chain of the given block. + pub fn should_extend_payload(&self, block_root: &Hash256) -> Result<bool, Error<T::Error>> { + let proposer_boost_root = self.fc_store.proposer_boost_root(); + self.proto_array + .should_extend_payload::<E>(block_root, proposer_boost_root) + .map_err(Error::ProtoArrayStringError) + } + /// Returns an `ExecutionStatus` if the block is known **and** a descendant of the finalized root. pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option<ExecutionStatus> { if self.is_finalized_checkpoint_or_descendant(*block_root) { @@ -1267,6 +1534,29 @@ where } } + /// Returns the canonical payload status of a block. See + /// `ProtoArrayForkChoice::get_canonical_payload_status`. + pub fn get_canonical_payload_status( + &self, + block_root: &Hash256, + spec: &ChainSpec, + ) -> Result<PayloadStatus, Error<T::Error>> { + if self.is_finalized_checkpoint_or_descendant(*block_root) { + let current_slot = self.fc_store.get_current_slot(); + let proposer_boost_root = self.fc_store.proposer_boost_root(); + self.proto_array + .get_canonical_payload_status::<E>( + block_root, + current_slot, + proposer_boost_root, + spec, + ) + .map_err(Error::ProtoArrayError) + } else { + Err(Error::DoesNotDescendFromFinalizedCheckpoint) + } + } + /// Returns the weight for the given block root. pub fn get_block_weight(&self, block_root: &Hash256) -> Option<u64> { self.proto_array.get_weight(block_root) @@ -1365,13 +1655,15 @@ where /// Returns the latest message for a given validator, if any. /// - /// Returns `(block_root, block_slot)`. + /// Returns `block_root, block_slot, payload_present`. /// /// ## Notes /// /// It may be prudent to call `Self::update_time` before calling this function, /// since some attestations might be queued and awaiting processing. - pub fn latest_message(&self, validator_index: usize) -> Option<(Hash256, Epoch)> { + /// + /// This function is only used in tests. + pub fn latest_message(&self, validator_index: usize) -> Option<LatestMessage> { self.proto_array.latest_message(validator_index) } @@ -1416,7 +1708,6 @@ where persisted_proto_array: proto_array::core::SszContainer, justified_balances: JustifiedBalances, reset_payload_statuses: ResetPayloadStatuses, - spec: &ChainSpec, ) -> Result<ProtoArrayForkChoice, Error<T::Error>> { let mut proto_array = ProtoArrayForkChoice::from_container( persisted_proto_array.clone(), @@ -1441,7 +1732,7 @@ where // Reset all blocks back to being "optimistic". This helps recover from an EL consensus // fault where an invalid payload becomes valid. - if let Err(e) = proto_array.set_all_blocks_to_optimistic::<E>(spec) { + if let Err(e) = proto_array.set_all_blocks_to_optimistic::<E>() { // If there is an error resetting the optimistic status then log loudly and revert // back to a proto-array which does not have the reset applied. This indicates a // significant error in Lighthouse and warrants detailed investigation. @@ -1471,7 +1762,6 @@ where persisted.proto_array, justified_balances, reset_payload_statuses, - spec, )?; let current_slot = fc_store.get_current_slot(); @@ -1479,7 +1769,7 @@ where let mut fork_choice = Self { fc_store, proto_array, - queued_attestations: persisted.queued_attestations, + queued_attestations: vec![], // Will be updated in the following call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1505,7 +1795,7 @@ where // get a different result. fork_choice .proto_array - .set_all_blocks_to_optimistic::<E>(spec)?; + .set_all_blocks_to_optimistic::<E>()?; // If the second attempt at finding a head fails, return an error since we do not // expect this scenario. fork_choice.get_head(current_slot, spec)?; @@ -1518,10 +1808,7 @@ where /// be instantiated again later. pub fn to_persisted(&self) -> PersistedForkChoice { PersistedForkChoice { - proto_array: self - .proto_array() - .as_ssz_container(self.justified_checkpoint(), self.finalized_checkpoint()), - queued_attestations: self.queued_attestations().to_vec(), + proto_array: self.proto_array().as_ssz_container(), } } @@ -1535,43 +1822,34 @@ where /// /// This is used when persisting the state of the fork choice to disk. #[superstruct( - variants(V17, V28), + variants(V28, V29), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] pub struct PersistedForkChoice { - #[superstruct(only(V17))] - pub proto_array_bytes: Vec<u8>, #[superstruct(only(V28))] - pub proto_array: proto_array::core::SszContainerV28, - pub queued_attestations: Vec<QueuedAttestation>, + pub proto_array_v28: proto_array::core::SszContainerV28, + #[superstruct(only(V29))] + pub proto_array: proto_array::core::SszContainerV29, + #[superstruct(only(V28))] + pub queued_attestations_v28: Vec<QueuedAttestationV28>, } -pub type PersistedForkChoice = PersistedForkChoiceV28; +pub type PersistedForkChoice = PersistedForkChoiceV29; -impl TryFrom<PersistedForkChoiceV17> for PersistedForkChoiceV28 { - type Error = ssz::DecodeError; - - fn try_from(v17: PersistedForkChoiceV17) -> Result<Self, Self::Error> { - let container_v17 = - proto_array::core::SszContainerV17::from_ssz_bytes(&v17.proto_array_bytes)?; - let container_v28 = container_v17.into(); - - Ok(Self { - proto_array: container_v28, - queued_attestations: v17.queued_attestations, - }) +impl From<PersistedForkChoiceV28> for PersistedForkChoiceV29 { + fn from(v28: PersistedForkChoiceV28) -> Self { + Self { + proto_array: v28.proto_array_v28.into(), + } } } -impl From<(PersistedForkChoiceV28, JustifiedBalances)> for PersistedForkChoiceV17 { - fn from((v28, balances): (PersistedForkChoiceV28, JustifiedBalances)) -> Self { - let container_v17 = proto_array::core::SszContainerV17::from((v28.proto_array, balances)); - let proto_array_bytes = container_v17.as_ssz_bytes(); - +impl From<PersistedForkChoiceV29> for PersistedForkChoiceV28 { + fn from(v29: PersistedForkChoiceV29) -> Self { Self { - proto_array_bytes, - queued_attestations: v28.queued_attestations, + proto_array_v28: v29.proto_array.into(), + queued_attestations_v28: vec![], } } } @@ -1611,6 +1889,7 @@ mod tests { attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), + payload_present: false, }) .collect() } diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index afe06dee1b..159eab0ec0 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -4,10 +4,11 @@ mod metrics; pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, - InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, - PersistedForkChoiceV17, PersistedForkChoiceV28, QueuedAttestation, ResetPayloadStatuses, + InvalidAttestation, InvalidBlock, InvalidPayloadAttestation, PayloadVerificationStatus, + PersistedForkChoice, PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, + ResetPayloadStatuses, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ - Block as ProtoBlock, ExecutionStatus, InvalidationOperation, ProposerHeadError, + Block as ProtoBlock, ExecutionStatus, InvalidationOperation, PayloadStatus, ProposerHeadError, }; diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index d3a84ee85b..353893026b 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -7,9 +7,11 @@ use beacon_chain::{ BeaconChain, BeaconChainError, BeaconForkChoiceStore, ChainConfig, ForkChoiceError, StateSkipConfig, WhenSlotSkipped, }; +use bls::AggregateSignature; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - ForkChoiceStore, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, QueuedAttestation, + AttestationFromBlock, ForkChoiceStore, InvalidAttestation, InvalidBlock, + InvalidPayloadAttestation, PayloadVerificationStatus, QueuedAttestation, }; use state_processing::state_advance::complete_state_advance; use std::fmt; @@ -19,8 +21,8 @@ use store::MemoryStore; use types::SingleAttestation; use types::{ BeaconBlockRef, BeaconState, ChainSpec, Checkpoint, Epoch, EthSpec, ForkName, Hash256, - IndexedAttestation, MainnetEthSpec, RelativeEpoch, SignedBeaconBlock, Slot, SubnetId, - test_utils::generate_deterministic_keypair, + IndexedAttestation, IndexedPayloadAttestation, MainnetEthSpec, PayloadAttestationData, + RelativeEpoch, SignedBeaconBlock, Slot, SubnetId, test_utils::generate_deterministic_keypair, }; pub type E = MainnetEthSpec; @@ -71,6 +73,9 @@ impl ForkChoiceTest { Self { harness } } + /// Creates a new tester with the Gloas fork active at epoch 1. + /// Genesis is a standard Fulu block (epoch 0), so block production works normally. + /// Tests that need Gloas semantics should advance the chain into epoch 1 first. /// Get a value from the `ForkChoice` instantiation. fn get<T, U>(&self, func: T) -> U where @@ -311,6 +316,7 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, + block.message().proposer_index(), &self.harness.chain.spec, ) .unwrap(); @@ -354,6 +360,7 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, + block.message().proposer_index(), &self.harness.chain.spec, ) .expect_err("on_block did not return an error"); @@ -923,6 +930,56 @@ async fn invalid_attestation_future_block() { .await; } +/// Gossip payload attestations must be for the current slot. A payload attestation for slot S +/// received at slot S+1 should be rejected per the spec. +#[tokio::test] +async fn non_block_payload_attestation_for_previous_slot_is_rejected() { + let test = ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await; + + let chain = &test.harness.chain; + let block_a = chain + .block_at_slot(Slot::new(1), WhenSlotSkipped::Prev) + .expect("lookup should succeed") + .expect("block A should exist"); + let block_a_root = block_a.canonical_root(); + let s_plus_1 = block_a.slot().saturating_add(1_u64); + + let payload_attestation = IndexedPayloadAttestation::<E> { + attesting_indices: vec![0_u64].try_into().expect("valid attesting indices"), + data: PayloadAttestationData { + beacon_block_root: block_a_root, + slot: Slot::new(1), + payload_present: true, + blob_data_available: true, + }, + signature: AggregateSignature::empty(), + }; + + let ptc = &[0_usize]; + + let result = chain + .canonical_head + .fork_choice_write_lock() + .on_payload_attestation( + s_plus_1, + &payload_attestation, + AttestationFromBlock::False, + ptc, + ); + assert!( + matches!( + result, + Err(ForkChoiceError::InvalidPayloadAttestation( + InvalidPayloadAttestation::PayloadAttestationNotCurrentSlot { .. } + )) + ), + "gossip payload attestation for previous slot should be rejected, got: {:?}", + result + ); +} + /// Specification v0.12.1: /// /// assert target.root == get_ancestor(store, attestation.data.beacon_block_root, target_slot) diff --git a/consensus/int_to_bytes/Cargo.toml b/consensus/int_to_bytes/Cargo.toml index c639dfce8d..75196d7437 100644 --- a/consensus/int_to_bytes/Cargo.toml +++ b/consensus/int_to_bytes/Cargo.toml @@ -9,4 +9,4 @@ bytes = { workspace = true } [dev-dependencies] hex = { workspace = true } -yaml-rust2 = "0.8" +yaml-rust2 = "0.11" diff --git a/consensus/merkle_proof/src/lib.rs b/consensus/merkle_proof/src/lib.rs index 494c73d05c..9952975e86 100644 --- a/consensus/merkle_proof/src/lib.rs +++ b/consensus/merkle_proof/src/lib.rs @@ -1,4 +1,4 @@ -use ethereum_hashing::{ZERO_HASHES, hash, hash32_concat}; +use ethereum_hashing::{ZERO_HASHES, hash32_concat}; use safe_arith::ArithError; use std::sync::LazyLock; @@ -382,20 +382,19 @@ pub fn verify_merkle_proof( pub fn merkle_root_from_branch(leaf: H256, branch: &[H256], depth: usize, index: usize) -> H256 { assert_eq!(branch.len(), depth, "proof length should equal depth"); - let mut merkle_root = leaf.as_slice().to_vec(); + let mut merkle_root = leaf.0; - for (i, leaf) in branch.iter().enumerate().take(depth) { + for (i, branch_node) in branch.iter().enumerate().take(depth) { let ith_bit = (index >> i) & 0x01; - if ith_bit == 1 { - merkle_root = hash32_concat(leaf.as_slice(), &merkle_root)[..].to_vec(); + let (left, right) = if ith_bit == 1 { + (branch_node.as_slice(), merkle_root.as_slice()) } else { - let mut input = merkle_root; - input.extend_from_slice(leaf.as_slice()); - merkle_root = hash(&input); - } + (merkle_root.as_slice(), branch_node.as_slice()) + }; + merkle_root = hash32_concat(left, right); } - H256::from_slice(&merkle_root) + H256::from(merkle_root) } impl From<ArithError> for MerkleTreeError { diff --git a/consensus/proto_array/Cargo.toml b/consensus/proto_array/Cargo.toml index 3f5bb3f4f5..c765db4e53 100644 --- a/consensus/proto_array/Cargo.toml +++ b/consensus/proto_array/Cargo.toml @@ -14,7 +14,9 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } safe_arith = { workspace = true } serde = { workspace = true } -serde_yaml = { workspace = true } +smallvec = { workspace = true } superstruct = { workspace = true } +typenum = { workspace = true } types = { workspace = true } tracing = { workspace = true } +yaml_serde = { workspace = true } diff --git a/consensus/proto_array/src/bin.rs b/consensus/proto_array/src/bin.rs index e1d307affb..38ba3150e7 100644 --- a/consensus/proto_array/src/bin.rs +++ b/consensus/proto_array/src/bin.rs @@ -22,5 +22,5 @@ fn main() { fn write_test_def_to_yaml(filename: &str, def: ForkChoiceTestDefinition) { let file = File::create(filename).expect("Should be able to open file"); - serde_yaml::to_writer(file, &def).expect("Should be able to write YAML to file"); + yaml_serde::to_writer(file, &def).expect("Should be able to write YAML to file"); } diff --git a/consensus/proto_array/src/error.rs b/consensus/proto_array/src/error.rs index 35cb4007b7..bb47af97d9 100644 --- a/consensus/proto_array/src/error.rs +++ b/consensus/proto_array/src/error.rs @@ -54,6 +54,14 @@ pub enum Error { }, InvalidEpochOffset(u64), Arith(ArithError), + InvalidNodeVariant { + block_root: Hash256, + }, + BrokenBlock { + block_root: Hash256, + }, + NoViableChildren, + OnBlockRequiresProposerIndex, } impl From<ArithError> for Error { diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 6087bdd7ad..41d6b5c4cc 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -1,20 +1,25 @@ mod execution_status; mod ffg_updates; +mod gloas_payload; mod no_votes; mod votes; -use crate::proto_array_fork_choice::{Block, ExecutionStatus, ProtoArrayForkChoice}; +use crate::error::Error; +use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice}; use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::BitVector; use std::collections::{BTreeSet, HashMap}; +use std::time::Duration; use types::{ - AttestationShufflingId, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, + AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, Slot, }; pub use execution_status::*; pub use ffg_updates::*; +pub use gloas_payload::*; pub use no_votes::*; pub use votes::*; @@ -25,6 +30,11 @@ pub enum Operation { finalized_checkpoint: Checkpoint, justified_state_balances: Vec<u64>, expected_head: Hash256, + current_slot: Slot, + // TODO(gloas): Make this non-optional. `find_head` always returns a `PayloadStatus` + // (Empty for pre-GLOAS), so every test should assert on it explicitly. + #[serde(default)] + expected_payload_status: Option<PayloadStatus>, }, ProposerBoostFindHead { justified_checkpoint: Checkpoint, @@ -44,11 +54,29 @@ pub enum Operation { parent_root: Hash256, justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, + #[serde(default)] + execution_payload_parent_hash: Option<ExecutionBlockHash>, + #[serde(default)] + execution_payload_block_hash: Option<ExecutionBlockHash>, }, ProcessAttestation { validator_index: usize, block_root: Hash256, - target_epoch: Epoch, + attestation_slot: Slot, + }, + ProcessGloasAttestation { + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, + payload_present: bool, + }, + ProcessPayloadAttestation { + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, + payload_present: bool, + #[serde(default)] + blob_data_available: bool, }, Prune { finalized_root: Hash256, @@ -63,6 +91,39 @@ pub enum Operation { block_root: Hash256, weight: u64, }, + AssertPayloadWeights { + block_root: Hash256, + expected_full_weight: u64, + expected_empty_weight: u64, + }, + AssertParentPayloadStatus { + block_root: Hash256, + expected_status: PayloadStatus, + }, + SetPayloadTiebreak { + block_root: Hash256, + is_timely: bool, + is_data_available: bool, + }, + /// Simulate receiving and validating an execution payload for `block_root`. + /// Sets `payload_received = true` on the V29 node via the live validation path. + ProcessExecutionPayloadEnvelope { + block_root: Hash256, + }, + AssertPayloadReceived { + block_root: Hash256, + expected: bool, + }, + AssertPayloadStatusByWeight { + block_root: Hash256, + expected_status: PayloadStatus, + /// Override `current_slot`. Defaults to the `current_slot` of the last `FindHead`. + #[serde(default)] + current_slot: Option<Slot>, + /// Override the proposer boost root. Defaults to `Hash256::zero()`. + #[serde(default)] + proposer_boost_root: Option<Hash256>, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,12 +132,23 @@ pub struct ForkChoiceTestDefinition { pub justified_checkpoint: Checkpoint, pub finalized_checkpoint: Checkpoint, pub operations: Vec<Operation>, + #[serde(default)] + pub execution_payload_parent_hash: Option<ExecutionBlockHash>, + #[serde(default)] + pub execution_payload_block_hash: Option<ExecutionBlockHash>, + #[serde(skip)] + pub spec: Option<ChainSpec>, } impl ForkChoiceTestDefinition { pub fn run(self) { - let mut spec = MainnetEthSpec::default_spec(); - spec.proposer_score_boost = Some(50); + let spec = self.spec.unwrap_or_else(|| { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + // Legacy test definitions target pre-Gloas behaviour unless explicitly overridden. + spec.gloas_fork_epoch = None; + spec + }); let junk_shuffling_id = AttestationShufflingId::from_components(Epoch::new(0), Hash256::zero()); @@ -90,9 +162,14 @@ impl ForkChoiceTestDefinition { junk_shuffling_id, HashMap::new(), ExecutionStatus::Optimistic(ExecutionBlockHash::zero()), + self.execution_payload_parent_hash, + self.execution_payload_block_hash, + 0, + &spec, ) .expect("should create fork choice struct"); let equivocating_indices = BTreeSet::new(); + let mut last_current_slot = Slot::new(0); for (op_index, op) in self.operations.into_iter().enumerate() { match op.clone() { @@ -101,18 +178,20 @@ impl ForkChoiceTestDefinition { finalized_checkpoint, justified_state_balances, expected_head, + current_slot, + expected_payload_status, } => { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) .unwrap(); - let head = fork_choice + let (head, payload_status) = fork_choice .find_head::<MainnetEthSpec>( justified_checkpoint, finalized_checkpoint, &justified_balances, Hash256::zero(), &equivocating_indices, - Slot::new(0), + current_slot, &spec, ) .unwrap_or_else(|e| { @@ -124,6 +203,23 @@ impl ForkChoiceTestDefinition { "Operation at index {} failed head check. Operation: {:?}", op_index, op ); + if let Some(expected_status) = expected_payload_status { + assert_eq!( + payload_status, expected_status, + "Operation at index {} failed payload status check. Operation: {:?}", + op_index, op + ); + } + assert_canonical_payload_status_matches_find_head( + &fork_choice, + &head, + current_slot, + Hash256::zero(), + &spec, + payload_status, + op_index, + ); + last_current_slot = current_slot; check_bytes_round_trip(&fork_choice); } Operation::ProposerBoostFindHead { @@ -136,7 +232,7 @@ impl ForkChoiceTestDefinition { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) .unwrap(); - let head = fork_choice + let (head, payload_status) = fork_choice .find_head::<MainnetEthSpec>( justified_checkpoint, finalized_checkpoint, @@ -155,6 +251,15 @@ impl ForkChoiceTestDefinition { "Operation at index {} failed head check. Operation: {:?}", op_index, op ); + assert_canonical_payload_status_matches_find_head( + &fork_choice, + &head, + Slot::new(0), + proposer_boost_root, + &spec, + payload_status, + op_index, + ); check_bytes_round_trip(&fork_choice); } Operation::InvalidFindHead { @@ -189,6 +294,8 @@ impl ForkChoiceTestDefinition { parent_root, justified_checkpoint, finalized_checkpoint, + execution_payload_parent_hash, + execution_payload_block_hash, } => { let block = Block { slot, @@ -212,14 +319,12 @@ impl ForkChoiceTestDefinition { ), unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, + execution_payload_parent_hash, + execution_payload_block_hash, + proposer_index: Some(0), }; fork_choice - .process_block::<MainnetEthSpec>( - block, - slot, - self.justified_checkpoint, - self.finalized_checkpoint, - ) + .process_block::<MainnetEthSpec>(block, slot, &spec, Duration::ZERO) .unwrap_or_else(|e| { panic!( "process_block op at index {} returned error: {:?}", @@ -231,10 +336,10 @@ impl ForkChoiceTestDefinition { Operation::ProcessAttestation { validator_index, block_root, - target_epoch, + attestation_slot, } => { fork_choice - .process_attestation(validator_index, block_root, target_epoch) + .process_attestation(validator_index, block_root, attestation_slot, false) .unwrap_or_else(|_| { panic!( "process_attestation op at index {} returned error", @@ -243,6 +348,49 @@ impl ForkChoiceTestDefinition { }); check_bytes_round_trip(&fork_choice); } + Operation::ProcessGloasAttestation { + validator_index, + block_root, + attestation_slot, + payload_present, + } => { + fork_choice + .process_attestation( + validator_index, + block_root, + attestation_slot, + payload_present, + ) + .unwrap_or_else(|_| { + panic!( + "process_attestation op at index {} returned error", + op_index + ) + }); + check_bytes_round_trip(&fork_choice); + } + Operation::ProcessPayloadAttestation { + validator_index, + block_root, + attestation_slot: _, + payload_present, + blob_data_available, + } => { + fork_choice + .process_payload_attestation( + block_root, + validator_index, + payload_present, + blob_data_available, + ) + .unwrap_or_else(|_| { + panic!( + "process_payload_attestation op at index {} returned error", + op_index + ) + }); + check_bytes_round_trip(&fork_choice); + } Operation::Prune { finalized_root, prune_threshold, @@ -288,8 +436,173 @@ impl ForkChoiceTestDefinition { Operation::AssertWeight { block_root, weight } => assert_eq!( fork_choice.get_weight(&block_root).unwrap(), weight, - "block weight" + "block weight at op index {}", + op_index ), + Operation::AssertPayloadWeights { + block_root, + expected_full_weight, + expected_empty_weight, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "AssertPayloadWeights: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get(*block_index) + .unwrap_or_else(|| { + panic!( + "AssertPayloadWeights: node not found at op index {}", + op_index + ) + }); + let v29 = node.as_v29().unwrap_or_else(|_| { + panic!( + "AssertPayloadWeights: node is not V29 at op index {}", + op_index + ) + }); + assert_eq!( + v29.full_payload_weight, expected_full_weight, + "full_payload_weight mismatch at op index {}", + op_index + ); + assert_eq!( + v29.empty_payload_weight, expected_empty_weight, + "empty_payload_weight mismatch at op index {}", + op_index + ); + } + Operation::AssertParentPayloadStatus { + block_root, + expected_status, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "AssertParentPayloadStatus: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get(*block_index) + .unwrap_or_else(|| { + panic!( + "AssertParentPayloadStatus: node not found at op index {}", + op_index + ) + }); + let v29 = node.as_v29().unwrap_or_else(|_| { + panic!( + "AssertParentPayloadStatus: node is not V29 at op index {}", + op_index + ) + }); + assert_eq!( + v29.parent_payload_status, expected_status, + "parent_payload_status mismatch at op index {}", + op_index + ); + } + Operation::SetPayloadTiebreak { + block_root, + is_timely, + is_data_available, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "SetPayloadTiebreak: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get_mut(*block_index) + .unwrap_or_else(|| { + panic!( + "SetPayloadTiebreak: node not found at op index {}", + op_index + ) + }); + let node_v29 = node.as_v29_mut().unwrap_or_else(|_| { + panic!( + "SetPayloadTiebreak: node is not V29 at op index {}", + op_index + ) + }); + // Set all bits (exceeds any threshold) or clear all bits. + let fill = if is_timely { 0xFF } else { 0x00 }; + node_v29.payload_timeliness_votes = + BitVector::from_bytes(smallvec::smallvec![fill; 64]) + .expect("valid 512-bit bitvector"); + let fill = if is_data_available { 0xFF } else { 0x00 }; + node_v29.payload_data_availability_votes = + BitVector::from_bytes(smallvec::smallvec![fill; 64]) + .expect("valid 512-bit bitvector"); + // Per spec, is_payload_timely/is_payload_data_available require + // the payload to be in payload_states (payload_received). + node_v29.payload_received = is_timely || is_data_available; + } + Operation::ProcessExecutionPayloadEnvelope { block_root } => { + fork_choice + .on_valid_payload_envelope_received(block_root) + .unwrap_or_else(|e| { + panic!( + "on_execution_payload op at index {} returned error: {}", + op_index, e + ) + }); + check_bytes_round_trip(&fork_choice); + } + Operation::AssertPayloadReceived { + block_root, + expected, + } => { + let actual = fork_choice.is_payload_received(&block_root); + assert_eq!( + actual, expected, + "payload_received mismatch at op index {}", + op_index + ); + } + Operation::AssertPayloadStatusByWeight { + block_root, + expected_status, + current_slot, + proposer_boost_root, + } => { + let actual = fork_choice + .get_canonical_payload_status::<MainnetEthSpec>( + &block_root, + current_slot.unwrap_or(last_current_slot), + proposer_boost_root.unwrap_or_else(Hash256::zero), + &spec, + ) + .unwrap(); + assert_eq!( + actual, expected_status, + "canonical payload status mismatch at op index {}", + op_index + ); + } } } } @@ -314,9 +627,39 @@ fn get_checkpoint(i: u64) -> Checkpoint { } } +/// Checks that `get_canonical_payload_status` agrees with the `payload_status` +/// returned by `find_head` for the head block. +fn assert_canonical_payload_status_matches_find_head( + fork_choice: &ProtoArrayForkChoice, + head: &Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + spec: &ChainSpec, + expected: PayloadStatus, + op_index: usize, +) { + match fork_choice.get_canonical_payload_status::<MainnetEthSpec>( + head, + current_slot, + proposer_boost_root, + spec, + ) { + Ok(actual) => assert_eq!( + actual, expected, + "get_canonical_payload_status disagreed with find_head for head {:?} at op index {}", + head, op_index + ), + // Skip the check for pre-gloas nodes + Err(Error::InvalidNodeVariant { .. }) => {} + Err(e) => panic!( + "get_canonical_payload_status failed at op index {}: {:?}", + op_index, e + ), + } +} + fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { - // The checkpoint are ignored `ProtoArrayForkChoice::from_bytes` so any value is ok - let bytes = original.as_bytes(Checkpoint::default(), Checkpoint::default()); + let bytes = original.as_bytes(); let decoded = ProtoArrayForkChoice::from_bytes(&bytes, original.balances.clone()) .expect("fork choice should decode from bytes"); assert!( diff --git a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs index aa26a84306..794310ef89 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs @@ -16,6 +16,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -35,6 +37,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -53,6 +57,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -73,6 +79,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -91,6 +99,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -101,7 +111,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -120,6 +130,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -143,7 +155,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -162,6 +174,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -196,6 +210,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -216,6 +232,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -245,7 +263,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is still 2 @@ -266,6 +284,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -315,6 +335,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Invalidation of 3 should have removed upstream weight. @@ -347,7 +369,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head has switched back to 1 @@ -368,6 +390,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -399,6 +423,9 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -418,6 +445,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -437,6 +466,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -455,6 +486,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -475,6 +508,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -493,6 +528,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -503,7 +540,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -522,6 +559,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -545,7 +584,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -564,6 +603,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -598,6 +639,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -618,6 +661,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -647,7 +692,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Move validator #1 vote from 2 to 3 @@ -660,7 +705,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is now 3. @@ -681,6 +726,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -730,6 +777,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Invalidation of 3 should have removed upstream weight. @@ -763,6 +812,9 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -782,6 +834,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -801,6 +855,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -819,6 +875,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -839,6 +897,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -857,6 +917,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -867,7 +929,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -886,6 +948,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -909,7 +973,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 1. @@ -928,6 +992,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -962,6 +1028,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is now 3, applying a proposer boost to 3 as well. @@ -985,13 +1053,15 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { proposer_boost_root: get_root(3), }); + // Stored weights are pure attestation scores (proposer boost is applied + // on-the-fly in the walk's `get_weight`, not baked into `node.weight()`). ops.push(Operation::AssertWeight { block_root: get_root(0), - weight: 33_250, + weight: 2_000, }); ops.push(Operation::AssertWeight { block_root: get_root(1), - weight: 33_250, + weight: 2_000, }); ops.push(Operation::AssertWeight { block_root: get_root(2), @@ -999,8 +1069,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }); ops.push(Operation::AssertWeight { block_root: get_root(3), - // This is a "magic number" generated from `calculate_committee_fraction`. - weight: 31_250, + weight: 0, }); // Invalidate the payload of 3. @@ -1065,6 +1134,9 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs index 3b31616145..76f9a95315 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs @@ -10,6 +10,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Build the following tree (stick? lol). @@ -27,6 +29,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(0), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -34,6 +38,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(1), justified_checkpoint: get_checkpoint(1), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -41,6 +47,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(2), justified_checkpoint: get_checkpoint(2), finalized_checkpoint: get_checkpoint(1), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that with justified epoch 0 we find 3 @@ -57,6 +65,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that with justified epoch 1 we find 3 @@ -77,6 +87,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that with justified epoch 2 we find 3 @@ -93,6 +105,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(1), justified_state_balances: balances, expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // END OF TESTS @@ -101,6 +115,9 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -114,6 +131,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Build the following tree. @@ -137,6 +156,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(0), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -147,6 +168,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -157,6 +180,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(4), @@ -167,6 +192,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(5), @@ -177,6 +204,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(3), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Right branch @@ -186,6 +215,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(0), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -193,6 +224,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(2), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -200,6 +233,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(4), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(4), @@ -210,6 +245,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(2), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(5), @@ -220,6 +257,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(4), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). @@ -240,6 +279,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above, but with justified epoch 2. ops.push(Operation::FindHead { @@ -250,6 +291,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above, but with justified epoch 3. // @@ -264,6 +307,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to 1. @@ -282,7 +327,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(0), + attestation_slot: Slot::new(0), }); // Ensure that if we start at 0 we find 9 (just: 0, fin: 0). @@ -303,6 +348,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Save as above but justified epoch 2. ops.push(Operation::FindHead { @@ -313,6 +360,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Save as above but justified epoch 3. // @@ -327,6 +376,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to 2. @@ -345,7 +396,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(0), + attestation_slot: Slot::new(0), }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). @@ -366,6 +417,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -376,6 +429,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 3. // @@ -390,6 +445,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that if we start at 1 we find 9 (just: 0, fin: 0). @@ -413,6 +470,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -423,6 +482,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 3. // @@ -437,6 +498,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that if we start at 2 we find 10 (just: 0, fin: 0). @@ -457,6 +520,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -467,6 +532,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 3. // @@ -481,6 +548,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances, expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // END OF TESTS @@ -489,6 +558,9 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs new file mode 100644 index 0000000000..ac4f8992c4 --- /dev/null +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -0,0 +1,1238 @@ +use super::*; + +fn gloas_spec() -> ChainSpec { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + spec.gloas_fork_epoch = Some(Epoch::new(0)); + spec +} + +pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build two branches off genesis where one child extends parent's payload chain (Full) + // and the other does not (Empty). + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Extend both branches to verify that head selection follows the selected chain. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + }); + + // With equal full/empty parent weights, tiebreak decides which chain to follow. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Cross-slot attestation with payload_present=true to Full branch (root 3, slot 2). + // vote_slot=3 differs from block_slot=2 and payload_present=true, so it counts as Full weight. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(3), + attestation_slot: Slot::new(3), + payload_present: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Full weight propagated up: root 0 and root 1 should show Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + // Root 2 has no payload received, so it's always Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + + // Cross-slot attestations with payload_present=false to Empty branch (root 4, slot 2). + // Two validators so Empty branch outweighs Full branch. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 1, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + }); + ops.push(Operation::ProcessGloasAttestation { + validator_index: 2, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], + expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Empty weight now dominates, so root 0 flips to Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + // Root 1 (Full branch) still has 1 Full vote and 0 Empty, so it stays Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + +pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1: child of genesis. Genesis has execution_payload_block_hash=zero + // (no execution payload at genesis), so all children have parent_payload_status=Empty. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + + // One Full and one Empty vote for the same head block: tie probes via runtime tiebreak, + // which defaults to Empty unless timely+data-available evidence is set. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: true, + blob_data_available: false, + }); + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 1, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(0), + // With MainnetEthSpec PTC_SIZE=512 and a 256-bit threshold, 1 bit set is not timely, so Empty. + expected_payload_status: Some(PayloadStatus::Empty), + }); + // PTC votes write to bitfields only, not to full/empty weight. + // Weight is 0 because no CL attestations target this block. + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(1), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + + // Flip validator 0 to Empty; both bits now clear. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(3), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: Some(PayloadStatus::Empty), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(1), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + + // Same-slot attestation to a new head candidate should be Pending (no payload bucket change). + // Root 5 is an Empty child of root_1 (parent_hash doesn't match root_1's block_hash), + // so it's reachable through root_1's Empty direction (root_1 has no payload_received). + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(5), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(101)), + execution_payload_block_hash: Some(get_hash(5)), + }); + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 2, + block_root: get_root(5), + attestation_slot: Slot::new(3), + payload_present: true, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], + expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: Some(PayloadStatus::Empty), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(5), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + // Genesis has zero execution block hash (no payload at genesis), which + // ensures all children get parent_payload_status=Empty. + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + +/// Test that CL attestation weight can flip the head between Full/Empty branches, +/// overriding the tiebreaker. +pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Competing branches with distinct payload ancestry (Full vs Empty from genesis). + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Equal branch weights: tiebreak FULL picks branch rooted at 3. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // CL attestation to Empty branch (root 4) from validator 0 flips the head to 4. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // CL attestation back to Full branch (root 3) returns the head to 3. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(3), + attestation_slot: Slot::new(4), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + +/// CL attestation weight overrides payload preference tiebreaker. +pub fn get_gloas_weight_priority_over_payload_preference_test_definition() +-> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build two branches where one child extends payload (Full) and the other doesn't (Empty). + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Parent prefers Full on equal branch weights (tiebreaker). + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Two CL attestations to the Empty branch make it strictly heavier, + // overriding the Full tiebreaker. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + +pub fn get_gloas_parent_empty_when_child_points_to_grandparent_test_definition() +-> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build a three-block chain A -> B -> C (CL parent links). + // A: EL parent = genesis hash(0), EL hash = hash(1). + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + + // B: EL parent = hash(1), EL hash = hash(2). + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // C: CL parent is B, but EL parent points to A (hash 1), not B (hash 2). + // This models B's payload not arriving in time, so C records parent status as Empty. + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(3), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(3), + expected_status: PayloadStatus::Empty, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + +/// Test interleaving of blocks, regular attestations, and tiebreaker. +/// +/// genesis → block 1 (Full) → block 3 +/// → block 2 (Empty) → block 4 +/// +/// With equal CL weight, tiebreaker determines which branch wins. +/// An extra CL attestation can override the tiebreaker. +pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Step 1: Two competing blocks at slot 1. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Step 2: Regular attestations arrive, one per branch (equal CL weight). + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(1), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(2), + attestation_slot: Slot::new(1), + }); + + // Step 3: Child blocks at slot 2. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Step 4: Set tiebreaker to Empty on genesis so the Empty branch wins. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: false, + is_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(4), + current_slot: Slot::new(1), + expected_payload_status: None, + }); + // Weights are tied (1 vote each branch), tiebreaker is Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + + // Step 5: Flip tiebreaker to Full so the Full branch wins. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(3), + current_slot: Slot::new(100), + expected_payload_status: None, + }); + // Weights still tied, tiebreaker flipped to Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + + // Step 6: Add extra CL weight to the Empty branch; this overrides the Full tiebreaker. + ops.push(Operation::ProcessAttestation { + validator_index: 2, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], + expected_head: get_root(4), + current_slot: Slot::new(100), + expected_payload_status: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } +} + +/// Test interleaving of blocks, payload validation, and attestations. +/// +/// Scenario (branching at block 1 since genesis has no payload): +/// - Genesis block (slot 0) with zero execution block hash +/// - Block 1 (slot 1) child of genesis (Empty parent status since genesis hash=zero) +/// - Block 2 (slot 2) extends block 1 Full chain (parent_hash matches block 1's block_hash) +/// - Block 3 (slot 2) extends block 1 Empty chain (parent_hash doesn't match) +/// - Before payload arrives: payload_received is false for block 1, only Empty reachable +/// - Process execution payload for block 1 → payload_received becomes true +/// - Both Full and Empty directions from block 1 become available +/// - With equal weight, tiebreaker prefers Full → Block 2 wins +pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1: child of genesis. Genesis has zero block hash, so + // parent_payload_status = Empty regardless of block 1's execution_payload_parent_hash. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + + // Block 2 at slot 2: Full child of block 1 (parent_hash matches block 1's block_hash). + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Block 3 at slot 2: Empty child of block 1 (parent_hash doesn't match block 1's block_hash). + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(3)), + }); + + // Verify parent_payload_status is set correctly. + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: PayloadStatus::Full, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(3), + expected_status: PayloadStatus::Empty, + }); + + // Genesis does NOT have payload_received (no payload at genesis). + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(0), + expected: false, + }); + + // Block 1 does not have payload_received yet. + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(1), + expected: false, + }); + + // Give one vote to each competing child so they have equal weight. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(2), + attestation_slot: Slot::new(2), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(3), + attestation_slot: Slot::new(2), + }); + + // Before payload_received on block 1: only Empty direction available. + // Block 3 (Empty child) is reachable, Block 2 (Full child) is not. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(3), + current_slot: Slot::new(100), + expected_payload_status: None, + }); + + // Process execution payload envelope for block 1 → payload_received becomes true. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(1), + expected: true, + }); + + // After payload_received on block 1: both Full and Empty directions available. + // Equal weight, tiebreaker prefers Full → Block 2 (Full child) wins. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(2), + current_slot: Slot::new(100), + expected_payload_status: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + // Genesis has zero execution block hash (no payload at genesis). + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + +/// When `current_slot == node.slot + 1`, spec `get_weight` zeroes out Full and Empty +/// weights so the tiebreaker decides. Tests that the zero-out is applied and +/// doesn't just compare raw payload weights. +pub fn get_gloas_previous_slot_tiebreaker_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1 with its payload received. + // Genesis has zero block hash so all its children are Empty (genesis never has + // payload_received). Block 1's parent_hash doesn't match zero → Empty child. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Block 2 at slot 2 with a mismatched EL parent hash, giving it an Empty parent payload status. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // More Full weight than Empty on block 1. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: true, + }); + + // Materialize the attestation into `full_payload_weight`. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(1), + current_slot: Slot::new(1), + expected_payload_status: Some(PayloadStatus::Full), + }); + + // Before zero-out (current_slot == block 1's slot), raw weights decide payload status (Full) + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: Some(Slot::new(1)), + proposer_boost_root: None, + }); + + // At current_slot == block 1's slot + 1, both weights zero out and the + // tiebreaker picks Empty (block 2 extends block 1 with an Empty parent + // payload status). + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + current_slot: Some(Slot::new(2)), + proposer_boost_root: Some(get_root(2)), + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + +/// Proposer boost on a descendant can flip an ancestor's canonical payload status. +/// Boost supports the ancestor's Full variant (via the descendant's Full parent +/// payload status) but not Empty, so a large enough boost overrides raw Empty weight. +pub fn get_gloas_proposer_boost_flips_ancestor_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1 with payload received. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Block 2 at slot 3 with a Full parent payload status (skip slot 2 so + // block 1's previous-slot zero-out doesn't fire at current_slot 3). + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // One Empty vote on block 1. Balance totals are chosen so the proposer + // boost score exceeds the single Empty voter's balance. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: false, + }); + + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![100, 10000], + expected_head: get_root(1), + current_slot: Slot::new(3), + expected_payload_status: Some(PayloadStatus::Empty), + }); + + // Without boost the raw weights decide and Empty wins. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + current_slot: Some(Slot::new(3)), + proposer_boost_root: None, + }); + + // With boost on block 2 the boost supports block 1's Full variant, so Full wins. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: Some(Slot::new(3)), + proposer_boost_root: Some(get_root(2)), + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gloas_fork_boundary_spec() -> ChainSpec { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + spec.gloas_fork_epoch = Some(Epoch::new(1)); + spec + } + + /// Gloas fork boundary: a chain starting pre-Gloas (V17 nodes) that crosses into + /// Gloas (V29 nodes). The head should advance through the fork boundary. + /// + /// Parameters: + /// - `skip_first_gloas_slot`: if true, there is no block at the first Gloas slot (slot 32); + /// the first V29 block appears at slot 33. + /// - `first_gloas_block_full`: if true, the first V29 block extends the parent V17 node's + /// EL chain (Full parent payload status). If false, it doesn't (Empty). + fn get_gloas_fork_boundary_test_definition( + skip_first_gloas_slot: bool, + first_gloas_block_full: bool, + ) -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block at slot 31 — last pre-Gloas slot. Created as a V17 node because + // gloas_fork_epoch = 1 means Gloas starts at slot 32. + // + // The test harness sets execution_status = Optimistic(ExecutionBlockHash::from_root(root)), + // so this V17 node's EL block hash = ExecutionBlockHash::from_root(get_root(1)). + ops.push(Operation::ProcessBlock { + slot: Slot::new(31), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + }); + + // First Gloas block (V29 node). + let gloas_slot = if skip_first_gloas_slot { 33 } else { 32 }; + + // The first Gloas block should always have the pre-Gloas block as its execution parent, + // although this is currently not checked anywhere (the spec doesn't mention this). + ops.push(Operation::ProcessBlock { + slot: Slot::new(gloas_slot), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Parent payload status of fork boundary block should always be Empty. + let expected_parent_status = PayloadStatus::Empty; + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: expected_parent_status, + }); + + // Mark root 2's execution payload as received so the Full virtual child exists. + if first_gloas_block_full { + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(2), + }); + } + + // Extend the chain with another V29 block (Full child of root 2). + ops.push(Operation::ProcessBlock { + slot: Slot::new(gloas_slot + 1), + root: get_root(3), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: if first_gloas_block_full { + Some(get_hash(2)) + } else { + Some(get_hash(1)) + }, + execution_payload_block_hash: Some(get_hash(3)), + }); + + // Head should advance to the tip of the chain through the fork boundary. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(gloas_slot + 1), + expected_payload_status: None, + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(3), + expected_status: if first_gloas_block_full { + PayloadStatus::Full + } else { + PayloadStatus::Empty + }, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + // Genesis is V17 (slot 0 < Gloas fork slot 32), these are unused for V17. + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: Some(gloas_fork_boundary_spec()), + } + } + + #[test] + fn fork_boundary_no_skip_full() { + get_gloas_fork_boundary_test_definition(false, true).run(); + } + + #[test] + fn fork_boundary_no_skip_empty() { + get_gloas_fork_boundary_test_definition(false, false).run(); + } + + #[test] + fn fork_boundary_skip_first_gloas_slot_full() { + get_gloas_fork_boundary_test_definition(true, true).run(); + } + + #[test] + fn fork_boundary_skip_first_gloas_slot_empty() { + get_gloas_fork_boundary_test_definition(true, false).run(); + } + + #[test] + fn chain_following() { + let test = get_gloas_chain_following_test_definition(); + test.run(); + } + + #[test] + fn payload_probe() { + let test = get_gloas_payload_probe_test_definition(); + test.run(); + } + + #[test] + fn find_head_vote_transition() { + let test = get_gloas_find_head_vote_transition_test_definition(); + test.run(); + } + + #[test] + fn weight_priority_over_payload_preference() { + let test = get_gloas_weight_priority_over_payload_preference_test_definition(); + test.run(); + } + + #[test] + fn parent_empty_when_child_points_to_grandparent() { + let test = get_gloas_parent_empty_when_child_points_to_grandparent_test_definition(); + test.run(); + } + + #[test] + fn interleaved_attestations() { + let test = get_gloas_interleaved_attestations_test_definition(); + test.run(); + } + + #[test] + fn payload_received_interleaving() { + let test = get_gloas_payload_received_interleaving_test_definition(); + test.run(); + } + + #[test] + fn previous_slot_tiebreaker() { + let test = get_gloas_previous_slot_tiebreaker_test_definition(); + test.run(); + } + + #[test] + fn proposer_boost_flips_ancestor() { + let test = get_gloas_proposer_boost_flips_ancestor_test_definition(); + test.run(); + } + + /// Test that execution payload invalidation propagates across the V17→V29 fork + /// boundary: after invalidating a V17 parent, head must not select any descendant. + /// + /// genesis(V17) -> block_1(V17, slot 31) -> block_2(V29, slot 32) + #[test] + fn mixed_v17_v29_invalidation() { + let balances = vec![1]; + let mut ops = vec![]; + + // V17 block at slot 31 (pre-Gloas). + ops.push(Operation::ProcessBlock { + slot: Slot::new(31), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + }); + + // V29 block at slot 32 (first Gloas slot), child of block 1. + ops.push(Operation::ProcessBlock { + slot: Slot::new(32), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Vote for block 2 (V29) so both blocks have weight. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(2), + attestation_slot: Slot::new(32), + }); + + // FindHead triggers apply_score_changes which materializes the vote. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: balances.clone(), + expected_head: get_root(2), + current_slot: Slot::new(32), + expected_payload_status: None, + }); + + // Invalidate block 1 (V17). filter_block_tree excludes the entire branch. + ops.push(Operation::InvalidatePayload { + head_block_root: get_root(1), + latest_valid_ancestor_root: Some(get_hash(0)), + }); + + // Head falls back to genesis — the invalid branch is no longer selectable. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: balances.clone(), + expected_head: get_root(0), + current_slot: Slot::new(32), + expected_payload_status: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: Some(gloas_fork_boundary_spec()), + } + .run(); + } +} diff --git a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs index d20eaacb99..7b5ee31c64 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs @@ -18,6 +18,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: Hash256::zero(), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 2 // @@ -36,6 +38,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is 2 // @@ -53,6 +57,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 1 // @@ -71,6 +77,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is still 2 // @@ -88,6 +96,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 3 // @@ -108,6 +118,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure 2 is still the head // @@ -127,6 +139,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 4 // @@ -147,6 +161,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is 4. // @@ -166,6 +182,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 5 with a justified epoch of 2 // @@ -185,6 +203,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is now 5 whilst the justified epoch is 0. // @@ -206,6 +226,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Ensure there is no error when starting from a block that has the // wrong justified epoch. @@ -232,6 +254,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Set the justified epoch to 2 and the start block to 5 and ensure 5 is the head. // @@ -250,6 +274,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 6 // @@ -271,6 +297,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure 6 is the head // @@ -291,6 +319,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(6), + current_slot: Slot::new(0), + expected_payload_status: None, }, ]; @@ -305,6 +335,9 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { root: Hash256::zero(), }, operations, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/votes.rs b/consensus/proto_array/src/fork_choice_test_definition/votes.rs index 01994fff9b..ac97a592b7 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/votes.rs @@ -16,6 +16,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -35,6 +37,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -53,6 +57,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -73,6 +79,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -91,6 +99,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -101,7 +111,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -120,6 +130,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 2 @@ -130,7 +142,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -149,6 +161,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 3. @@ -170,6 +184,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -190,6 +206,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Move validator #0 vote from 1 to 3 @@ -202,7 +220,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is still 2 @@ -223,6 +241,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Move validator #1 vote from 2 to 1 (this is an equivocation, but fork choice doesn't @@ -236,7 +256,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is now 3 @@ -257,6 +277,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 4. @@ -280,6 +302,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is now 4 @@ -302,6 +326,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 5, which has a justified epoch of 2. @@ -327,19 +353,22 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(1), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); - // Ensure that 5 is filtered out and the head stays at 4. + // Block 5 has incompatible finalized checkpoint, so `get_filtered_block_tree` + // excludes the entire 1->3->4->5 branch (no viable leaf). Head moves to 2. // // 0 // / \ - // 2 1 + // head-> 2 1 // | // 3 // | - // 4 <- head + // 4 // / - // 5 + // 5 <- incompatible finalized checkpoint ops.push(Operation::FindHead { justified_checkpoint: Checkpoint { epoch: Epoch::new(1), @@ -350,7 +379,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, justified_state_balances: balances.clone(), - expected_head: get_root(4), + expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 6, which has a justified epoch of 0. @@ -376,6 +407,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Move both votes to 5. @@ -392,12 +425,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(5), - target_epoch: Epoch::new(4), + attestation_slot: Slot::new(4), }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(5), - target_epoch: Epoch::new(4), + attestation_slot: Slot::new(4), }); // Add blocks 7, 8 and 9. Adding these blocks helps test the `best_descendant` @@ -430,6 +463,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(0), @@ -443,6 +478,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(0), @@ -456,6 +493,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that 6 is the head, even though 5 has all the votes. This is testing to ensure @@ -487,6 +526,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(6), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -520,6 +561,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -545,12 +588,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(9), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(9), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); // Add block 10 @@ -582,6 +625,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Double-check the head is still 9 (no diagram this time) @@ -596,6 +641,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Introduce 2 more validators into the system @@ -621,12 +668,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 2, block_root: get_root(10), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); ops.push(Operation::ProcessAttestation { validator_index: 3, block_root: get_root(10), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); // Check the head is now 10. @@ -657,6 +704,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Set the balances of the last two validators to zero @@ -682,6 +731,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Set the balances of the last two validators back to 1 @@ -707,6 +758,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Remove the last two validators @@ -733,6 +786,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that pruning below the prune threshold does not prune. @@ -754,6 +809,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that pruning above the prune threshold does prune. @@ -792,6 +849,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 11 @@ -817,6 +876,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure the head is now 11 @@ -841,6 +902,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(11), + current_slot: Slot::new(0), + expected_payload_status: None, }); ForkChoiceTestDefinition { @@ -854,6 +917,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index 964e836d91..702c014f07 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -8,13 +8,13 @@ mod ssz_container; pub use crate::justified_balances::JustifiedBalances; pub use crate::proto_array::{InvalidationOperation, calculate_committee_fraction}; pub use crate::proto_array_fork_choice::{ - Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, ProposerHeadError, - ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, + Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, LatestMessage, PayloadStatus, + ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; pub use error::Error; pub mod core { pub use super::proto_array::{ProposerBoost, ProtoArray, ProtoNode}; pub use super::proto_array_fork_choice::VoteTracker; - pub use super::ssz_container::{SszContainer, SszContainerV17, SszContainerV28}; + pub use super::ssz_container::{SszContainer, SszContainerV28, SszContainerV29}; } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 135801ce33..78a0c0490b 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1,13 +1,19 @@ use crate::error::InvalidBestNodeInfo; -use crate::{Block, ExecutionStatus, JustifiedBalances, error::Error}; +use crate::proto_array_fork_choice::IndexedForkChoiceNode; +use crate::{ + Block, ExecutionStatus, JustifiedBalances, LatestMessage, PayloadStatus, error::Error, +}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::BitVector; use ssz::Encode; use ssz::four_byte_option_impl; use ssz_derive::{Decode, Encode}; use std::collections::{HashMap, HashSet}; +use std::time::Duration; use superstruct::superstruct; use tracing::info; +use typenum::U512; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, Slot, @@ -69,47 +75,151 @@ impl InvalidationOperation { } } -pub type ProtoNode = ProtoNodeV17; - #[superstruct( - variants(V17), - variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)), - no_enum + variants(V17, V29), + variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)) )] +#[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Clone)] +#[ssz(enum_behaviour = "union")] pub struct ProtoNode { /// The `slot` is not necessary for `ProtoArray`, it just exists so external components can /// easily query the block slot. This is useful for upstream fork choice logic. + #[superstruct(getter(copy))] pub slot: Slot, /// The `state_root` is not necessary for `ProtoArray` either, it also just exists for upstream /// components (namely attestation verification). + #[superstruct(getter(copy))] pub state_root: Hash256, /// The root that would be used for the `attestation.data.target.root` if a LMD vote was cast /// for this block. /// /// The `target_root` is not necessary for `ProtoArray` either, it also just exists for upstream /// components (namely fork choice attestation verification). + #[superstruct(getter(copy))] pub target_root: Hash256, pub current_epoch_shuffling_id: AttestationShufflingId, pub next_epoch_shuffling_id: AttestationShufflingId, + #[superstruct(getter(copy))] pub root: Hash256, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_usize")] pub parent: Option<usize>, - #[superstruct(only(V17))] + #[superstruct(only(V17, V29), partial_getter(copy))] pub justified_checkpoint: Checkpoint, - #[superstruct(only(V17))] + #[superstruct(only(V17, V29), partial_getter(copy))] pub finalized_checkpoint: Checkpoint, + #[superstruct(getter(copy))] pub weight: u64, + #[superstruct(only(V17), partial_getter(copy))] #[ssz(with = "four_byte_option_usize")] pub best_child: Option<usize>, + #[superstruct(only(V17), partial_getter(copy))] #[ssz(with = "four_byte_option_usize")] pub best_descendant: Option<usize>, /// Indicates if an execution node has marked this block as valid. Also contains the execution - /// block hash. + /// block hash. This is only used pre-Gloas. + #[superstruct(only(V17), partial_getter(copy))] pub execution_status: ExecutionStatus, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_checkpoint")] pub unrealized_justified_checkpoint: Option<Checkpoint>, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_checkpoint")] pub unrealized_finalized_checkpoint: Option<Checkpoint>, + + /// We track the parent payload status from which the current node was extended. + #[superstruct(only(V29), partial_getter(copy))] + pub parent_payload_status: PayloadStatus, + #[superstruct(only(V29), partial_getter(copy))] + pub empty_payload_weight: u64, + #[superstruct(only(V29), partial_getter(copy))] + pub full_payload_weight: u64, + #[superstruct(only(V29), partial_getter(copy))] + pub execution_payload_block_hash: ExecutionBlockHash, + #[superstruct(only(V29), partial_getter(copy))] + pub execution_payload_parent_hash: ExecutionBlockHash, + /// Equivalent to spec's `block_timeliness[root][ATTESTATION_TIMELINESS_INDEX]`. + #[superstruct(only(V29), partial_getter(copy))] + pub block_timeliness_attestation_threshold: bool, + /// Equivalent to spec's `block_timeliness[root][PTC_TIMELINESS_INDEX]`. + #[superstruct(only(V29), partial_getter(copy))] + pub block_timeliness_ptc_threshold: bool, + /// Equivalent to spec's `store.payload_timeliness_vote[root]`. + /// PTC timeliness vote bitfield, indexed by PTC committee position. + /// Bit i set means PTC member i voted `payload_present = true`. + /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. + #[superstruct(only(V29))] + pub payload_timeliness_votes: BitVector<U512>, + /// Equivalent to spec's `store.payload_data_availability_vote[root]`. + /// PTC data availability vote bitfield, indexed by PTC committee position. + /// Bit i set means PTC member i voted `blob_data_available = true`. + /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. + #[superstruct(only(V29))] + pub payload_data_availability_votes: BitVector<U512>, + /// Whether the execution payload for this block has been received and validated locally. + /// Maps to `root in store.payload_states` in the spec. + #[superstruct(only(V29), partial_getter(copy))] + pub payload_received: bool, + /// The proposer index for this block, used by `should_apply_proposer_boost` + /// to detect equivocations at the parent's slot. + #[superstruct(only(V29), partial_getter(copy))] + pub proposer_index: u64, + /// Weight from equivocating validators that voted for this block. + /// Used by `is_head_weak` to match the spec's monotonicity guarantee: + /// more attestations can only increase head weight, never decrease it. + #[superstruct(only(V29), partial_getter(copy))] + pub equivocating_attestation_score: u64, +} + +impl ProtoNode { + /// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by + /// considering their parents Empty. + pub fn get_parent_payload_status(&self) -> PayloadStatus { + self.parent_payload_status().unwrap_or(PayloadStatus::Empty) + } + + pub fn is_parent_node_full(&self) -> bool { + self.get_parent_payload_status() == PayloadStatus::Full + } + + pub fn attestation_score(&self, payload_status: PayloadStatus) -> u64 { + match payload_status { + PayloadStatus::Pending => self.weight(), + // Pre-Gloas (V17) nodes have no payload separation — all weight + // is in `weight()`. Post-Gloas (V29) nodes track per-status weights. + PayloadStatus::Empty => self + .empty_payload_weight() + .unwrap_or_else(|_| self.weight()), + PayloadStatus::Full => self.full_payload_weight().unwrap_or_else(|_| self.weight()), + } + } + + pub fn is_payload_timely<E: EthSpec>(&self) -> bool { + let Ok(node) = self.as_v29() else { + return false; + }; + + // Equivalent to `if root not in store.payload_states` in the spec. + if !node.payload_received { + return false; + } + + node.payload_timeliness_votes.num_set_bits() > E::payload_timely_threshold() + } + + pub fn is_payload_data_available<E: EthSpec>(&self) -> bool { + let Ok(node) = self.as_v29() else { + return false; + }; + + // Equivalent to `if root not in store.payload_states` in the spec. + if !node.payload_received { + return false; + } + + node.payload_data_availability_votes.num_set_bits() + > E::data_availability_timely_threshold() + } } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -127,6 +237,122 @@ impl Default for ProposerBoost { } } +/// Accumulated score changes for a single proto-array node during a `find_head` pass. +/// +/// `delta` tracks the ordinary LMD-GHOST balance change applied to the concrete block node. +/// This is the same notion of weight that pre-gloas fork choice used. +/// +/// +/// Under gloas we also need to track how votes contribute to the parent's virtual payload +/// branches: +/// +/// - `empty_delta` is the balance change attributable to votes that support the `Empty` payload +/// interpretation of the node +/// - `full_delta` is the balance change attributable to votes that support the `Full` payload +/// interpretation of the node +/// +/// Votes in `Pending` state only affect `delta`; they do not contribute to either payload bucket. +/// During score application these payload deltas are propagated independently up the tree so that +/// ancestors can compare children using payload-aware tie breaking. +#[derive(Clone, PartialEq, Debug, Copy)] +pub struct NodeDelta { + /// Total weight change for the node. All votes contribute regardless of payload status. + pub delta: i64, + /// Weight change from `PayloadStatus::Empty` votes. + pub empty_delta: i64, + /// Weight change from `PayloadStatus::Full` votes. + pub full_delta: i64, + /// Weight from equivocating validators that voted for this node. + pub equivocating_attestation_delta: u64, +} + +impl NodeDelta { + /// Classify a vote into the payload bucket it contributes to for `block_slot`. + /// + /// Per the gloas model: + /// + /// - a same-slot vote is `Pending` + /// - a later vote with `payload_present = true` is `Full` + /// - a later vote with `payload_present = false` is `Empty` + /// + /// This classification is used only for payload-aware accounting; all votes still contribute to + /// the aggregate `delta`. + pub fn payload_status( + vote_slot: Slot, + payload_present: bool, + block_slot: Slot, + ) -> PayloadStatus { + if vote_slot == block_slot { + PayloadStatus::Pending + } else if payload_present { + PayloadStatus::Full + } else { + PayloadStatus::Empty + } + } + + /// Add `balance` to the payload bucket selected by `status`. + /// + /// `Pending` votes do not affect payload buckets, so this becomes a no-op for that case. + pub fn add_payload_delta( + &mut self, + status: PayloadStatus, + balance: u64, + index: usize, + ) -> Result<(), Error> { + let field = match status { + PayloadStatus::Full => &mut self.full_delta, + PayloadStatus::Empty => &mut self.empty_delta, + PayloadStatus::Pending => return Ok(()), + }; + *field = field + .checked_add(balance as i64) + .ok_or(Error::DeltaOverflow(index))?; + Ok(()) + } + + /// Create a delta that only affects the aggregate block weight. + /// + /// This is useful for callers or tests that only care about ordinary LMD-GHOST weight changes + /// and do not need payload-aware accounting. + pub fn from_delta(delta: i64) -> Self { + Self { + delta, + empty_delta: 0, + full_delta: 0, + equivocating_attestation_delta: 0, + } + } + + /// Subtract `balance` from the payload bucket selected by `status`. + /// + /// `Pending` votes do not affect payload buckets, so this becomes a no-op for that case. + pub fn sub_payload_delta( + &mut self, + status: PayloadStatus, + balance: u64, + index: usize, + ) -> Result<(), Error> { + let field = match status { + PayloadStatus::Full => &mut self.full_delta, + PayloadStatus::Empty => &mut self.empty_delta, + PayloadStatus::Pending => return Ok(()), + }; + *field = field + .checked_sub(balance as i64) + .ok_or(Error::DeltaOverflow(index))?; + Ok(()) + } +} + +/// Compare NodeDelta with i64 by comparing the aggregate `delta` field. +/// This is used by tests that only care about the total weight delta. +impl PartialEq<i64> for NodeDelta { + fn eq(&self, other: &i64) -> bool { + self.delta == *other + } +} + #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] pub struct ProtoArray { /// Do not attempt to prune the tree unless it has at least this many nodes. Small prunes @@ -155,13 +381,7 @@ impl ProtoArray { #[allow(clippy::too_many_arguments)] pub fn apply_score_changes<E: EthSpec>( &mut self, - mut deltas: Vec<i64>, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - new_justified_balances: &JustifiedBalances, - proposer_boost_root: Hash256, - current_slot: Slot, - spec: &ChainSpec, + mut deltas: Vec<NodeDelta>, ) -> Result<(), Error> { if deltas.len() != self.indices.len() { return Err(Error::InvalidDeltaLen { @@ -170,9 +390,6 @@ impl ProtoArray { }); } - // Default the proposer boost score to zero. - let mut proposer_score = 0; - // Iterate backwards through all indices in `self.nodes`. for node_index in (0..self.nodes.len()).rev() { let node = self @@ -183,133 +400,95 @@ impl ProtoArray { // There is no need to adjust the balances or manage parent of the zero hash since it // is an alias to the genesis block. The weight applied to the genesis block is // irrelevant as we _always_ choose it and it's impossible for it to have a parent. - if node.root == Hash256::zero() { + if node.root() == Hash256::zero() { continue; } - let execution_status_is_invalid = node.execution_status.is_invalid(); - - // TODO(focil) seems sketchy... - // TODO(focil) improve loggings - // modify is viable for head - // debug/fork_choice - let mut node_delta = if execution_status_is_invalid - || node.root - == *self - .unsatisfied_inclusion_list_blocks - .get(¤t_slot) - .unwrap_or(&Hash256::ZERO) + let execution_status_is_invalid = if let Ok(proto_node) = node.as_v17() + && proto_node.execution_status.is_invalid() { - info!( - root = %node.root, - ?current_slot, - is_unsatisfied_il_block = self.unsatisfied_inclusion_list_blocks.contains_key(¤t_slot), - "Potentially unsatisfied IL block", - ); - // If the node has an invalid execution payload, or the payload doesn't satisfy - // an inclusion list, reduce its weight to zero. - 0_i64 - .checked_sub(node.weight as i64) - .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? + true } else { - deltas - .get(node_index) - .copied() - .ok_or(Error::InvalidNodeDelta(node_index))? + false }; - // If we find the node for which the proposer boost was previously applied, decrease - // the delta by the previous score amount. - if self.previous_proposer_boost.root != Hash256::zero() - && self.previous_proposer_boost.root == node.root - // Invalid nodes will always have a weight of zero so there's no need to subtract - // the proposer boost delta. - && !execution_status_is_invalid - { - node_delta = node_delta - .checked_sub(self.previous_proposer_boost.score as i64) - .ok_or(Error::DeltaOverflow(node_index))?; - } - // If we find the node matching the current proposer boost root, increase - // the delta by the new score amount (unless the block has an invalid execution status). - // - // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance - if let Some(proposer_score_boost) = spec.proposer_score_boost - && proposer_boost_root != Hash256::zero() - && proposer_boost_root == node.root - // Invalid nodes (or their ancestors) should not receive a proposer boost. - && !execution_status_is_invalid - { - proposer_score = - calculate_committee_fraction::<E>(new_justified_balances, proposer_score_boost) - .ok_or(Error::ProposerBoostOverflow(node_index))?; - node_delta = node_delta - .checked_add(proposer_score as i64) - .ok_or(Error::DeltaOverflow(node_index))?; - } + let node_delta = deltas + .get(node_index) + .copied() + .ok_or(Error::InvalidNodeDelta(node_index))?; + + let delta = if execution_status_is_invalid { + // If the node has an invalid execution payload, reduce its weight to zero. + 0_i64 + .checked_sub(node.weight() as i64) + .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? + } else { + node_delta.delta + }; + + let (node_empty_delta, node_full_delta) = if node.as_v29().is_ok() { + (node_delta.empty_delta, node_delta.full_delta) + } else { + (0, 0) + }; + + // Proposer boost is NOT applied here. It is computed on-the-fly + // during the virtual tree walk in `get_weight`, matching the spec's + // `get_weight` which adds boost separately from `get_attestation_score`. // Apply the delta to the node. if execution_status_is_invalid { // Invalid nodes always have a weight of 0. - node.weight = 0 - } else if node_delta < 0 { - // Note: I am conflicted about whether to use `saturating_sub` or `checked_sub` - // here. - // - // I can't think of any valid reason why `node_delta.abs()` should be greater than - // `node.weight`, so I have chosen `checked_sub` to try and fail-fast if there is - // some error. - // - // However, I am not fully convinced that some valid case for `saturating_sub` does - // not exist. - node.weight = node - .weight - .checked_sub(node_delta.unsigned_abs()) - .ok_or(Error::DeltaOverflow(node_index))?; + *node.weight_mut() = 0; } else { - node.weight = node - .weight - .checked_add(node_delta as u64) - .ok_or(Error::DeltaOverflow(node_index))?; + *node.weight_mut() = apply_delta(node.weight(), delta, node_index)?; + } + + // Apply post-Gloas score deltas. + if let Ok(node) = node.as_v29_mut() { + node.empty_payload_weight = + apply_delta(node.empty_payload_weight, node_empty_delta, node_index)?; + node.full_payload_weight = + apply_delta(node.full_payload_weight, node_full_delta, node_index)?; + node.equivocating_attestation_score = node + .equivocating_attestation_score + .saturating_add(node_delta.equivocating_attestation_delta); } // Update the parent delta (if any). - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { let parent_delta = deltas .get_mut(parent_index) .ok_or(Error::InvalidParentDelta(parent_index))?; - // Back-propagate the nodes delta to its parent. - *parent_delta += node_delta; - } - } + // Back-propagate the node's delta to its parent. + parent_delta.delta = parent_delta + .delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; - // After applying all deltas, update the `previous_proposer_boost`. - self.previous_proposer_boost = ProposerBoost { - root: proposer_boost_root, - score: proposer_score, - }; - - // A second time, iterate backwards through all indices in `self.nodes`. - // - // We _must_ perform these functions separate from the weight-updating loop above to ensure - // that we have a fully coherent set of weights before updating parent - // best-child/descendant. - for node_index in (0..self.nodes.len()).rev() { - let node = self - .nodes - .get_mut(node_index) - .ok_or(Error::InvalidNodeIndex(node_index))?; - - // If the node has a parent, try to update its best-child and best-descendant. - if let Some(parent_index) = node.parent { - self.maybe_update_best_child_and_descendant::<E>( - parent_index, - node_index, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; + // Route ALL child weight into the parent's FULL or EMPTY bucket + // based on the child's `parent_payload_status` (the ancestor path + // direction). If this child is on the FULL path from the parent, + // all weight supports the parent's FULL virtual node, and vice versa. + if let Ok(child_v29) = node.as_v29() { + if child_v29.parent_payload_status == PayloadStatus::Full { + parent_delta.full_delta = parent_delta + .full_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } else { + parent_delta.empty_delta = parent_delta + .empty_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + } else { + // This is a v17 node with a v17 parent. + // There is no empty or full weight for v17 nodes, so nothing to propagate. + // In the tree walk, the v17 nodes have an empty child with 0 weight, which + // wins by default (it is the only child). + } } } @@ -323,71 +502,272 @@ impl ProtoArray { &mut self, block: Block, current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, + spec: &ChainSpec, + time_into_slot: Duration, ) -> Result<(), Error> { // If the block is already known, simply ignore it. if self.indices.contains_key(&block.root) { return Ok(()); } - let node_index = self.nodes.len(); - - let node = ProtoNode { - slot: block.slot, - root: block.root, - target_root: block.target_root, - current_epoch_shuffling_id: block.current_epoch_shuffling_id, - next_epoch_shuffling_id: block.next_epoch_shuffling_id, - state_root: block.state_root, - parent: block - .parent_root - .and_then(|parent| self.indices.get(&parent).copied()), - justified_checkpoint: block.justified_checkpoint, - finalized_checkpoint: block.finalized_checkpoint, - weight: 0, - best_child: None, - best_descendant: None, - execution_status: block.execution_status, - unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + // We do not allow `proposer_index=None` for calls to `on_block`, it is only non-optional + // for backwards-compatibility with pre-Gloas V17 proto nodes. + let Some(proposer_index) = block.proposer_index else { + return Err(Error::OnBlockRequiresProposerIndex); }; - // If the parent has an invalid execution status, return an error before adding the block to - // `self`. - if let Some(parent_index) = node.parent { + let node_index = self.nodes.len(); + + let parent_index = block + .parent_root + .and_then(|parent| self.indices.get(&parent).copied()); + + let node = if !spec.fork_name_at_slot::<E>(block.slot).gloas_enabled() { + ProtoNode::V17(ProtoNodeV17 { + slot: block.slot, + root: block.root, + target_root: block.target_root, + current_epoch_shuffling_id: block.current_epoch_shuffling_id, + next_epoch_shuffling_id: block.next_epoch_shuffling_id, + state_root: block.state_root, + parent: parent_index, + justified_checkpoint: block.justified_checkpoint, + finalized_checkpoint: block.finalized_checkpoint, + weight: 0, + best_child: None, + best_descendant: None, + execution_status: block.execution_status, + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + }) + } else { + let is_current_slot = current_slot == block.slot; + + let execution_payload_block_hash = + block + .execution_payload_block_hash + .ok_or(Error::BrokenBlock { + block_root: block.root, + })?; + + let execution_payload_parent_hash = + block + .execution_payload_parent_hash + .ok_or(Error::BrokenBlock { + block_root: block.root, + })?; + + let parent_payload_status: PayloadStatus = + if let Some(parent_node) = parent_index.and_then(|idx| self.nodes.get(idx)) { + match parent_node { + ProtoNode::V29(v29) => { + // Both parent and child are Gloas blocks. The parent is full if the + // block hash in the parent node matches the parent block hash in the + // child bid. + if execution_payload_parent_hash == v29.execution_payload_block_hash { + PayloadStatus::Full + } else { + PayloadStatus::Empty + } + } + ProtoNode::V17(_) => { + // Parent is pre-Gloas, pre-Gloas blocks are treated as having Empty + // payload status. This case is reached during the fork transition. + PayloadStatus::Empty + } + } + } else { + // Parent is missing (genesis or pruned due to finalization). This code path + // should only be hit at Gloas genesis. Default to empty, the genesis block + // has no payload enevelope. + PayloadStatus::Empty + }; + + // The spec does something slightly strange where it initialises the payload timeliness + // votes and payload data availability votes for the anchor block to all true, but never + // adds the anchor to `store.payloads`, so it is never considered full. + let is_anchor = parent_index.is_none(); + + ProtoNode::V29(ProtoNodeV29 { + slot: block.slot, + root: block.root, + target_root: block.target_root, + current_epoch_shuffling_id: block.current_epoch_shuffling_id, + next_epoch_shuffling_id: block.next_epoch_shuffling_id, + state_root: block.state_root, + parent: parent_index, + justified_checkpoint: block.justified_checkpoint, + finalized_checkpoint: block.finalized_checkpoint, + weight: 0, + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + parent_payload_status, + empty_payload_weight: 0, + full_payload_weight: 0, + execution_payload_block_hash, + execution_payload_parent_hash, + payload_timeliness_votes: BitVector::default(), + payload_data_availability_votes: BitVector::default(), + payload_received: false, + proposer_index, + // Spec: `record_block_timeliness` + `get_forkchoice_store`. + // Anchor gets [True, True]. Others computed from time_into_slot. + block_timeliness_attestation_threshold: is_anchor + || (is_current_slot + && time_into_slot < spec.get_attestation_due::<E>(current_slot)), + block_timeliness_ptc_threshold: is_anchor + || (is_current_slot && time_into_slot < spec.get_payload_attestation_due()), + equivocating_attestation_score: 0, + }) + }; + + // If the parent has an invalid execution status, return an error before adding the + // block to `self`. This applies only when the parent is a V17 node with execution tracking. + if let Some(parent_index) = node.parent() { let parent = self .nodes .get(parent_index) .ok_or(Error::InvalidNodeIndex(parent_index))?; - if parent.execution_status.is_invalid() { + + // Execution status tracking only exists on V17 (pre-Gloas) nodes. + if let Ok(v17) = parent.as_v17() + && v17.execution_status.is_invalid() + { return Err(Error::ParentExecutionStatusIsInvalid { block_root: block.root, - parent_root: parent.root, + parent_root: parent.root(), }); } } - self.indices.insert(node.root, node_index); + self.indices.insert(node.root(), node_index); self.nodes.push(node.clone()); - if let Some(parent_index) = node.parent { - self.maybe_update_best_child_and_descendant::<E>( - parent_index, - node_index, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - if matches!(block.execution_status, ExecutionStatus::Valid(_)) { - self.propagate_execution_payload_validation_by_index(parent_index)?; - } + if let Some(parent_index) = node.parent() + && matches!(block.execution_status, ExecutionStatus::Valid(_)) + { + self.propagate_execution_payload_validation_by_index(parent_index)?; } Ok(()) } + /// Spec: `is_head_weak`. + // TODO(gloas): the spec adds weight from equivocating validators in the + // head slot's *committees*, regardless of who they voted for. We approximate + // with `equivocating_attestation_score` which only tracks equivocating + // validators whose vote pointed at this block. This under-counts when an + // equivocating validator is in the committee but voted for a different fork, + // which could allow a re-org the spec wouldn't. In practice the deviation + // is small — it requires equivocating validators voting for competing forks + // AND the head weight to be exactly at the reorg threshold boundary. + // Fixing this properly requires committee computation from BeaconState, + // which is not available in proto_array. The fix would be to pass + // pre-computed equivocating committee weight from the beacon_chain caller. + fn is_head_weak<E: EthSpec>( + &self, + head_node: &ProtoNode, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> bool { + let reorg_threshold = calculate_committee_fraction::<E>( + justified_balances, + spec.reorg_head_weight_threshold.unwrap_or(20), + ) + .unwrap_or(0); + + let head_weight = head_node + .attestation_score(PayloadStatus::Pending) + .saturating_add(head_node.equivocating_attestation_score().unwrap_or(0)); + + head_weight < reorg_threshold + } + + /// Spec's `should_apply_proposer_boost` for Gloas. + /// + /// Returns `true` if the proposer boost should be kept. Returns `false` if the + /// boost should be subtracted (invalidated) because the parent is weak and there + /// are no equivocating blocks at the parent's slot. + fn should_apply_proposer_boost<E: EthSpec>( + &self, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result<bool, Error> { + if proposer_boost_root.is_zero() { + return Ok(false); + } + + let block_index = *self + .indices + .get(&proposer_boost_root) + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let block = self + .nodes + .get(block_index) + .ok_or(Error::InvalidNodeIndex(block_index))?; + let parent_index = block + .parent() + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let parent = self + .nodes + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))?; + let slot = block.slot(); + + // Apply proposer boost if `parent` is not from the previous slot + if parent.slot().saturating_add(1_u64) < slot { + return Ok(true); + } + + // Apply proposer boost if `parent` is not weak + if !self.is_head_weak::<E>(parent, justified_balances, spec) { + return Ok(true); + } + + // Parent is weak. Apply boost unless there's an equivocating block at + // the parent's slot from the same proposer. + let parent_slot = parent.slot(); + let parent_root = parent.root(); + let parent_proposer = parent.proposer_index(); + + let has_equivocation = self.nodes.iter().any(|node| { + if let Ok(timeliness) = node.block_timeliness_ptc_threshold() + && let Ok(proposer_index) = node.proposer_index() + { + timeliness + && Ok(proposer_index) == parent_proposer + && node.slot() == parent_slot + && node.root() != parent_root + } else { + // Pre-Gloas. + false + } + }); + + Ok(!has_equivocation) + } + + /// Process a valid execution payload envelope for a Gloas block. + /// + /// Sets `payload_received` to true. + pub fn on_valid_payload_envelope_received(&mut self, block_root: Hash256) -> Result<(), Error> { + let index = *self + .indices + .get(&block_root) + .ok_or(Error::NodeUnknown(block_root))?; + let node = self + .nodes + .get_mut(index) + .ok_or(Error::InvalidNodeIndex(index))?; + let v29 = node + .as_v29_mut() + .map_err(|_| Error::InvalidNodeVariant { block_root })?; + v29.payload_received = true; + + Ok(()) + } + /// Updates the `block_root` and all ancestors to have validated execution payloads. /// /// Returns an error if: @@ -407,6 +787,8 @@ impl ProtoArray { /// Updates the `verified_node_index` and all ancestors to have validated execution payloads. /// + /// This function is a no-op if called for a Gloas block. + /// /// Returns an error if: /// /// - The `verified_node_index` is unknown. @@ -421,32 +803,39 @@ impl ProtoArray { .nodes .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - let parent_index = match node.execution_status { - // We have reached a node that we already know is valid. No need to iterate further - // since we assume an ancestors have already been set to valid. - ExecutionStatus::Valid(_) => return Ok(()), - // We have reached an irrelevant node, this node is prior to a terminal execution - // block. There's no need to iterate further, it's impossible for this block to have - // any relevant ancestors. - ExecutionStatus::Irrelevant(_) => return Ok(()), - // The block has an unknown status, set it to valid since any ancestor of a valid - // payload can be considered valid. - ExecutionStatus::Optimistic(payload_block_hash) => { - node.execution_status = ExecutionStatus::Valid(payload_block_hash); - if let Some(parent_index) = node.parent { - parent_index - } else { - // We have reached the root block, iteration complete. - return Ok(()); + let parent_index = match node { + ProtoNode::V17(node) => match node.execution_status { + // We have reached a node that we already know is valid. No need to iterate further + // since we assume an ancestors have already been set to valid. + ExecutionStatus::Valid(_) => return Ok(()), + // We have reached an irrelevant node, this node is prior to a terminal execution + // block. There's no need to iterate further, it's impossible for this block to have + // any relevant ancestors. + ExecutionStatus::Irrelevant(_) => return Ok(()), + // The block has an unknown status, set it to valid since any ancestor of a valid + // payload can be considered valid. + ExecutionStatus::Optimistic(payload_block_hash) => { + node.execution_status = ExecutionStatus::Valid(payload_block_hash); + if let Some(parent_index) = node.parent { + parent_index + } else { + // We have reached the root block, iteration complete. + return Ok(()); + } } - } - // An ancestor of the valid payload was invalid. This is a serious error which - // indicates a consensus failure in the execution node. This is unrecoverable. - ExecutionStatus::Invalid(ancestor_payload_block_hash) => { - return Err(Error::InvalidAncestorOfValidPayload { - ancestor_block_root: node.root, - ancestor_payload_block_hash, - }); + // An ancestor of the valid payload was invalid. This is a serious error which + // indicates a consensus failure in the execution node. This is unrecoverable. + ExecutionStatus::Invalid(ancestor_payload_block_hash) => { + return Err(Error::InvalidAncestorOfValidPayload { + ancestor_block_root: node.root, + ancestor_payload_block_hash, + }); + } + }, + // Gloas nodes should not be marked valid by this function, which exists only + // for pre-Gloas fork choice. + ProtoNode::V29(_) => { + return Ok(()); } }; @@ -503,10 +892,11 @@ impl ProtoArray { .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - match node.execution_status { - ExecutionStatus::Valid(hash) - | ExecutionStatus::Invalid(hash) - | ExecutionStatus::Optimistic(hash) => { + let node_execution_status = node.execution_status(); + match node_execution_status { + Ok(ExecutionStatus::Valid(hash)) + | Ok(ExecutionStatus::Invalid(hash)) + | Ok(ExecutionStatus::Optimistic(hash)) => { // If we're no longer processing the `head_block_root` and the last valid // ancestor is unknown, exit this loop and proceed to invalidate and // descendants of `head_block_root`/`latest_valid_ancestor_root`. @@ -515,74 +905,51 @@ impl ProtoArray { // supplied, don't validate any ancestors. The alternative is to invalidate // *all* ancestors, which would likely involve shutting down the client due to // an invalid justified checkpoint. - if !latest_valid_ancestor_is_descendant && node.root != head_block_root { + if !latest_valid_ancestor_is_descendant && node.root() != head_block_root { break; } else if op.latest_valid_ancestor() == Some(hash) { - // If the `best_child` or `best_descendant` of the latest valid hash was - // invalidated, set those fields to `None`. - // - // In theory, an invalid `best_child` necessarily infers an invalid - // `best_descendant`. However, we check each variable independently to - // defend against errors which might result in an invalid block being set as - // head. - if node - .best_child - .is_some_and(|i| invalidated_indices.contains(&i)) - { - node.best_child = None - } - if node - .best_descendant - .is_some_and(|i| invalidated_indices.contains(&i)) - { - node.best_descendant = None - } - + // Reached latest valid block, stop invalidating further. break; } } - ExecutionStatus::Irrelevant(_) => break, + Ok(ExecutionStatus::Irrelevant(_)) => break, + Err(_) => break, } // Only invalidate the head block if either: // // - The head block was specifically indicated to be invalidated. // - The latest valid hash is a known ancestor. - if node.root != head_block_root + if node.root() != head_block_root || op.invalidate_block_root() || latest_valid_ancestor_is_descendant { - match &node.execution_status { + match node.execution_status() { // It's illegal for an execution client to declare that some previously-valid block // is now invalid. This is a consensus failure on their behalf. - ExecutionStatus::Valid(hash) => { + Ok(ExecutionStatus::Valid(hash)) => { return Err(Error::ValidExecutionStatusBecameInvalid { - block_root: node.root, - payload_block_hash: *hash, + block_root: node.root(), + payload_block_hash: hash, }); } - ExecutionStatus::Optimistic(hash) => { + Ok(ExecutionStatus::Optimistic(hash)) => { invalidated_indices.insert(index); - node.execution_status = ExecutionStatus::Invalid(*hash); - - // It's impossible for an invalid block to lead to a "best" block, so set these - // fields to `None`. - // - // Failing to set these values will result in `Self::node_leads_to_viable_head` - // returning `false` for *valid* ancestors of invalid blocks. - node.best_child = None; - node.best_descendant = None; + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Invalid(hash); + } } // The block is already invalid, but keep going backwards to ensure all ancestors // are updated. - ExecutionStatus::Invalid(_) => (), + Ok(ExecutionStatus::Invalid(_)) => (), // This block is pre-merge, therefore it has no execution status. Nor do its // ancestors. - ExecutionStatus::Irrelevant(_) => break, + Ok(ExecutionStatus::Irrelevant(_)) => break, + Err(_) => break, } } - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { index = parent_index } else { // The root of the block tree has been reached (aka the finalized block), without @@ -616,24 +983,27 @@ impl ProtoArray { .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - if let Some(parent_index) = node.parent + if let Some(parent_index) = node.parent() && invalidated_indices.contains(&parent_index) { - match &node.execution_status { - ExecutionStatus::Valid(hash) => { + match node.execution_status() { + Ok(ExecutionStatus::Valid(hash)) => { return Err(Error::ValidExecutionStatusBecameInvalid { - block_root: node.root, - payload_block_hash: *hash, + block_root: node.root(), + payload_block_hash: hash, }); } - ExecutionStatus::Optimistic(hash) | ExecutionStatus::Invalid(hash) => { - node.execution_status = ExecutionStatus::Invalid(*hash) + Ok(ExecutionStatus::Optimistic(hash)) | Ok(ExecutionStatus::Invalid(hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Invalid(hash) + } } - ExecutionStatus::Irrelevant(_) => { + Ok(ExecutionStatus::Irrelevant(_)) => { return Err(Error::IrrelevantDescendant { - block_root: node.root, + block_root: node.root(), }); } + Err(_) => (), } invalidated_indices.insert(index); @@ -651,13 +1021,17 @@ impl ProtoArray { /// been called without a subsequent `Self::apply_score_changes` call. This is because /// `on_new_block` does not attempt to walk backwards through the tree and update the /// best-child/best-descendant links. + #[allow(clippy::too_many_arguments)] pub fn find_head<E: EthSpec>( &self, justified_root: &Hash256, current_slot: Slot, best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, - ) -> Result<Hash256, Error> { + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result<(Hash256, PayloadStatus), Error> { let justified_index = self .indices .get(justified_root) @@ -671,25 +1045,30 @@ impl ProtoArray { // Since there are no valid descendants of a justified block with an invalid execution // payload, there would be no head to choose from. - // - // Fork choice is effectively broken until a new justified root is set. It might not be - // practically possible to set a new justified root if we are unable to find a new head. - // - // This scenario is *unsupported*. It represents a serious consensus failure. - if justified_node.execution_status.is_invalid() { + // Execution status tracking only exists on V17 (pre-Gloas) nodes. + if let Ok(v17) = justified_node.as_v17() + && v17.execution_status.is_invalid() + { return Err(Error::InvalidJustifiedCheckpointExecutionStatus { justified_root: *justified_root, }); } - let best_descendant_index = justified_node.best_descendant.unwrap_or(justified_index); - - let best_node = self - .nodes - .get(best_descendant_index) - .ok_or(Error::InvalidBestDescendant(best_descendant_index))?; + let best_fc_node = self.find_head_walk::<E>( + justified_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + proposer_boost_root, + justified_balances, + spec, + )?; // Perform a sanity check that the node is indeed valid to be the head. + let best_node = self + .nodes + .get(best_fc_node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(best_fc_node.proto_node_index))?; if !self.node_is_viable_for_head::<E>( best_node, current_slot, @@ -701,13 +1080,465 @@ impl ProtoArray { start_root: *justified_root, justified_checkpoint: best_justified_checkpoint, finalized_checkpoint: best_finalized_checkpoint, - head_root: best_node.root, - head_justified_checkpoint: best_node.justified_checkpoint, - head_finalized_checkpoint: best_node.finalized_checkpoint, + head_root: best_node.root(), + head_justified_checkpoint: *best_node.justified_checkpoint(), + head_finalized_checkpoint: *best_node.finalized_checkpoint(), }))); } - Ok(best_node.root) + Ok((best_fc_node.root, best_fc_node.payload_status)) + } + + /// Spec: `get_filtered_block_tree`. + /// + /// Returns the set of node indices on viable branches — those with at least + /// one leaf descendant with correct justified/finalized checkpoints. + fn get_filtered_block_tree<E: EthSpec>( + &self, + start_index: usize, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + ) -> HashSet<usize> { + let mut viable = HashSet::new(); + self.filter_block_tree::<E>( + start_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + &mut viable, + ); + viable + } + + /// Spec: `filter_block_tree`. + fn filter_block_tree<E: EthSpec>( + &self, + node_index: usize, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + viable: &mut HashSet<usize>, + ) -> bool { + let Some(node) = self.nodes.get(node_index) else { + return false; + }; + + // Skip invalid children — they aren't in store.blocks in the spec. + let children: Vec<usize> = self + .nodes + .iter() + .enumerate() + .filter(|(_, child)| { + child.parent() == Some(node_index) + && !child + .execution_status() + .is_ok_and(|status| status.is_invalid()) + }) + .map(|(i, _)| i) + .collect(); + + if !children.is_empty() { + // Evaluate ALL children (no short-circuit) to mark all viable branches. + let any_viable = children + .iter() + .map(|&child_index| { + self.filter_block_tree::<E>( + child_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + viable, + ) + }) + .collect::<Vec<_>>() + .into_iter() + .any(|v| v); + if any_viable { + viable.insert(node_index); + return true; + } + return false; + } + + // Leaf node: check viability. + if self.node_is_viable_for_head::<E>( + node, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ) { + viable.insert(node_index); + return true; + } + false + } + + /// Spec: `get_head`. + #[allow(clippy::too_many_arguments)] + fn find_head_walk<E: EthSpec>( + &self, + start_index: usize, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result<IndexedForkChoiceNode, Error> { + let mut head = IndexedForkChoiceNode { + root: best_justified_checkpoint.root, + proto_node_index: start_index, + payload_status: PayloadStatus::Pending, + }; + + // Spec: `get_filtered_block_tree`. + let viable_nodes = self.get_filtered_block_tree::<E>( + start_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ); + + // Compute once rather than per-child per-level. + let apply_proposer_boost = + self.should_apply_proposer_boost::<E>(proposer_boost_root, justified_balances, spec)?; + + loop { + let children: Vec<_> = self + .get_node_children(&head)? + .into_iter() + .filter(|(fc_node, _)| viable_nodes.contains(&fc_node.proto_node_index)) + .collect(); + + if children.is_empty() { + return Ok(head); + } + + head = children + .into_iter() + .map(|(child, ref proto_node)| -> Result<_, Error> { + let weight = self.get_weight::<E>( + &child, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + let payload_status_tiebreaker = self.get_payload_status_tiebreaker::<E>( + &child, + proto_node, + current_slot, + proposer_boost_root, + )?; + Ok((child, weight, payload_status_tiebreaker)) + }) + .collect::<Result<Vec<_>, Error>>()? + .into_iter() + .max_by_key(|(child, weight, payload_status_tiebreaker)| { + (*weight, child.root, *payload_status_tiebreaker) + }) + .map(|(child, _, _)| child) + .ok_or(Error::NoViableChildren)?; + } + } + + /// Returns the canonical payload status of a block, matching the decision + /// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`. + pub(crate) fn get_canonical_payload_status<E: EthSpec>( + &self, + root: Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result<PayloadStatus, Error> { + let proto_node_index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?; + let proto_node = self + .nodes + .get(proto_node_index) + .ok_or(Error::InvalidNodeIndex(proto_node_index))?; + + if !proto_node + .payload_received() + .map_err(|_| Error::InvalidNodeVariant { block_root: root })? + { + return Ok(PayloadStatus::Empty); + } + + let full_fc = IndexedForkChoiceNode { + root, + proto_node_index, + payload_status: PayloadStatus::Full, + }; + let empty_fc = IndexedForkChoiceNode { + root, + proto_node_index, + payload_status: PayloadStatus::Empty, + }; + + // Matches the hoisting optimization in `find_head`: `get_weight`'s spec-level + // `should_apply_proposer_boost` check is precomputed once. + let apply_proposer_boost = + self.should_apply_proposer_boost::<E>(proposer_boost_root, justified_balances, spec)?; + + let full_weight = self.get_weight::<E>( + &full_fc, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + + let empty_weight = self.get_weight::<E>( + &empty_fc, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + + match full_weight.cmp(&empty_weight) { + std::cmp::Ordering::Greater => Ok(PayloadStatus::Full), + std::cmp::Ordering::Less => Ok(PayloadStatus::Empty), + std::cmp::Ordering::Equal => { + let full_tb = self.get_payload_status_tiebreaker::<E>( + &full_fc, + proto_node, + current_slot, + proposer_boost_root, + )?; + let empty_tb = self.get_payload_status_tiebreaker::<E>( + &empty_fc, + proto_node, + current_slot, + proposer_boost_root, + )?; + if full_tb >= empty_tb { + Ok(PayloadStatus::Full) + } else { + Ok(PayloadStatus::Empty) + } + } + } + } + + /// Spec: `get_weight`. + #[allow(clippy::too_many_arguments)] + fn get_weight<E: EthSpec>( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + apply_proposer_boost: bool, + proposer_boost_root: Hash256, + current_slot: Slot, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result<u64, Error> { + if fc_node.payload_status == PayloadStatus::Pending + || proto_node.slot().saturating_add(1_u64) != current_slot + { + let attestation_score = proto_node.attestation_score(fc_node.payload_status); + + if !apply_proposer_boost { + return Ok(attestation_score); + } + + // Spec: proposer boost is treated as a synthetic vote. + let message = LatestMessage { + slot: current_slot, + root: proposer_boost_root, + payload_present: false, + }; + let proposer_score = if self.is_supporting_vote(fc_node, &message)? { + get_proposer_score::<E>(justified_balances, spec)? + } else { + 0 + }; + + Ok(attestation_score.saturating_add(proposer_score)) + } else { + Ok(0) + } + } + + /// Spec: `is_supporting_vote`. + fn is_supporting_vote( + &self, + node: &IndexedForkChoiceNode, + message: &LatestMessage, + ) -> Result<bool, Error> { + let block = self + .nodes + .get(node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(node.proto_node_index))?; + + if node.root == message.root { + if node.payload_status == PayloadStatus::Pending { + return Ok(true); + } + // For the proposer boost case: message.slot == current_slot == block.slot, + // so this returns false — boost does not support EMPTY/FULL of the + // boosted block itself, only its ancestors. + if message.slot <= block.slot() { + return Ok(false); + } + if message.payload_present { + Ok(node.payload_status == PayloadStatus::Full) + } else { + Ok(node.payload_status == PayloadStatus::Empty) + } + } else { + let ancestor = self.get_ancestor_node(message.root, block.slot())?; + Ok(node.root == ancestor.root + && (node.payload_status == PayloadStatus::Pending + || node.payload_status == ancestor.payload_status)) + } + } + + /// Spec: `get_ancestor` (modified to return ForkChoiceNode with payload_status). + fn get_ancestor_node(&self, root: Hash256, slot: Slot) -> Result<IndexedForkChoiceNode, Error> { + let index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?; + let block = self + .nodes + .get(index) + .ok_or(Error::InvalidNodeIndex(index))?; + + if block.slot() <= slot { + return Ok(IndexedForkChoiceNode { + root, + proto_node_index: index, + payload_status: PayloadStatus::Pending, + }); + } + + // Walk up until we find the ancestor at `slot`. + let mut child_index = index; + let mut current_index = block.parent().ok_or(Error::NodeUnknown(block.root()))?; + + loop { + let current = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + + if current.slot() <= slot { + let child = self + .nodes + .get(child_index) + .ok_or(Error::InvalidNodeIndex(child_index))?; + return Ok(IndexedForkChoiceNode { + root: current.root(), + proto_node_index: current_index, + payload_status: child.get_parent_payload_status(), + }); + } + + child_index = current_index; + current_index = current.parent().ok_or(Error::NodeUnknown(current.root()))?; + } + } + + /// Spec: `get_node_children`. + fn get_node_children( + &self, + node: &IndexedForkChoiceNode, + ) -> Result<Vec<(IndexedForkChoiceNode, ProtoNode)>, Error> { + if node.payload_status == PayloadStatus::Pending { + let proto_node = self + .nodes + .get(node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(node.proto_node_index))?; + let mut children = vec![(node.with_status(PayloadStatus::Empty), proto_node.clone())]; + // The FULL virtual child only exists if the payload has been received. + if proto_node.payload_received().is_ok_and(|received| received) { + children.push((node.with_status(PayloadStatus::Full), proto_node.clone())); + } + Ok(children) + } else { + Ok(self + .nodes + .iter() + .enumerate() + .filter(|(_, child_node)| { + child_node.parent() == Some(node.proto_node_index) + && child_node.get_parent_payload_status() == node.payload_status + }) + .map(|(child_index, child_node)| { + ( + IndexedForkChoiceNode { + root: child_node.root(), + proto_node_index: child_index, + payload_status: PayloadStatus::Pending, + }, + child_node.clone(), + ) + }) + .collect()) + } + } + + pub(crate) fn get_payload_status_tiebreaker<E: EthSpec>( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + current_slot: Slot, + proposer_boost_root: Hash256, + ) -> Result<u8, Error> { + if fc_node.payload_status == PayloadStatus::Pending + || proto_node.slot().saturating_add(1_u64) != current_slot + { + Ok(fc_node.payload_status as u8) + } else if fc_node.payload_status == PayloadStatus::Empty { + Ok(1) + } else if self.should_extend_payload::<E>(fc_node, proto_node, proposer_boost_root)? { + Ok(2) + } else { + Ok(0) + } + } + + pub fn should_extend_payload<E: EthSpec>( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + proposer_boost_root: Hash256, + ) -> Result<bool, Error> { + // Per spec: `proposer_root == Root()` is one of the `or` conditions that + // makes `should_extend_payload` return True. + if proposer_boost_root.is_zero() { + return Ok(true); + } + + let proposer_boost_node_index = *self + .indices + .get(&proposer_boost_root) + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let proposer_boost_node = self + .nodes + .get(proposer_boost_node_index) + .ok_or(Error::InvalidNodeIndex(proposer_boost_node_index))?; + + let parent_index = proposer_boost_node + .parent() + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let proposer_boost_parent_root = self + .nodes + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))? + .root(); + + Ok( + (proto_node.is_payload_timely::<E>() && proto_node.is_payload_data_available::<E>()) + || proposer_boost_parent_root != fc_node.root + || proposer_boost_node.is_parent_node_full(), + ) } /// Update the tree with new finalization information. The tree is only actually pruned if both @@ -740,7 +1571,7 @@ impl ProtoArray { .nodes .get(node_index) .ok_or(Error::InvalidNodeIndex(node_index))? - .root; + .root(); self.indices.remove(root); } @@ -757,176 +1588,15 @@ impl ProtoArray { // Iterate through all the existing nodes and adjust their indices to match the new layout // of `self.nodes`. for node in self.nodes.iter_mut() { - if let Some(parent) = node.parent { + if let Some(parent) = node.parent() { // If `node.parent` is less than `finalized_index`, set it to `None`. - node.parent = parent.checked_sub(finalized_index); - } - if let Some(best_child) = node.best_child { - node.best_child = Some( - best_child - .checked_sub(finalized_index) - .ok_or(Error::IndexOverflow("best_child"))?, - ); - } - if let Some(best_descendant) = node.best_descendant { - node.best_descendant = Some( - best_descendant - .checked_sub(finalized_index) - .ok_or(Error::IndexOverflow("best_descendant"))?, - ); + *node.parent_mut() = parent.checked_sub(finalized_index); } } Ok(()) } - /// Observe the parent at `parent_index` with respect to the child at `child_index` and - /// potentially modify the `parent.best_child` and `parent.best_descendant` values. - /// - /// ## Detail - /// - /// There are four outcomes: - /// - /// - The child is already the best child but it's now invalid due to a FFG change and should be removed. - /// - The child is already the best child and the parent is updated with the new - /// best-descendant. - /// - The child is not the best child but becomes the best child. - /// - The child is not the best child and does not become the best child. - fn maybe_update_best_child_and_descendant<E: EthSpec>( - &mut self, - parent_index: usize, - child_index: usize, - current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - ) -> Result<(), Error> { - let child = self - .nodes - .get(child_index) - .ok_or(Error::InvalidNodeIndex(child_index))?; - - let parent = self - .nodes - .get(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))?; - - let child_leads_to_viable_head = self.node_leads_to_viable_head::<E>( - child, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - // These three variables are aliases to the three options that we may set the - // `parent.best_child` and `parent.best_descendant` to. - // - // I use the aliases to assist readability. - let change_to_none = (None, None); - let change_to_child = ( - Some(child_index), - child.best_descendant.or(Some(child_index)), - ); - let no_change = (parent.best_child, parent.best_descendant); - - let (new_best_child, new_best_descendant) = - if let Some(best_child_index) = parent.best_child { - if best_child_index == child_index && !child_leads_to_viable_head { - // If the child is already the best-child of the parent but it's not viable for - // the head, remove it. - change_to_none - } else if best_child_index == child_index { - // If the child is the best-child already, set it again to ensure that the - // best-descendant of the parent is updated. - change_to_child - } else { - let best_child = self - .nodes - .get(best_child_index) - .ok_or(Error::InvalidBestDescendant(best_child_index))?; - - let best_child_leads_to_viable_head = self.node_leads_to_viable_head::<E>( - best_child, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - if child_leads_to_viable_head && !best_child_leads_to_viable_head { - // The child leads to a viable head, but the current best-child doesn't. - change_to_child - } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { - // The best child leads to a viable head, but the child doesn't. - no_change - } else if child.weight == best_child.weight { - // Tie-breaker of equal weights by root. - if child.root >= best_child.root { - change_to_child - } else { - no_change - } - } else { - // Choose the winner by weight. - if child.weight > best_child.weight { - change_to_child - } else { - no_change - } - } - } - } else if child_leads_to_viable_head { - // There is no current best-child and the child is viable. - change_to_child - } else { - // There is no current best-child but the child is not viable. - no_change - }; - - let parent = self - .nodes - .get_mut(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))?; - - parent.best_child = new_best_child; - parent.best_descendant = new_best_descendant; - - Ok(()) - } - - /// Indicates if the node itself is viable for the head, or if its best descendant is viable - /// for the head. - fn node_leads_to_viable_head<E: EthSpec>( - &self, - node: &ProtoNode, - current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - ) -> Result<bool, Error> { - let best_descendant_is_viable_for_head = - if let Some(best_descendant_index) = node.best_descendant { - let best_descendant = self - .nodes - .get(best_descendant_index) - .ok_or(Error::InvalidBestDescendant(best_descendant_index))?; - - self.node_is_viable_for_head::<E>( - best_descendant, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - ) - } else { - false - }; - - Ok(best_descendant_is_viable_for_head - || self.node_is_viable_for_head::<E>( - node, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )) - } - /// This is the equivalent to the `filter_block_tree` function in the eth2 spec: /// /// https://github.com/ethereum/eth2.0-specs/blob/v0.10.0/specs/phase0/fork-choice.md#filter_block_tree @@ -940,16 +1610,18 @@ impl ProtoArray { best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, ) -> bool { - if node.execution_status.is_invalid() { + if let Ok(proto_node) = node.as_v17() + && proto_node.execution_status.is_invalid() + { return false; } // TODO(focil) unwrap_or - if node.root + if node.root() == *self .unsatisfied_inclusion_list_blocks .get(¤t_slot) - .unwrap_or(&Hash256::ZERO) + .unwrap_or(&Hash256::zero()) { info!( ?current_slot, @@ -961,19 +1633,19 @@ impl ProtoArray { let genesis_epoch = Epoch::new(0); let current_epoch = current_slot.epoch(E::slots_per_epoch()); - let node_epoch = node.slot.epoch(E::slots_per_epoch()); - let node_justified_checkpoint = node.justified_checkpoint; + let node_epoch = node.slot().epoch(E::slots_per_epoch()); + let node_justified_checkpoint = node.justified_checkpoint(); let voting_source = if current_epoch > node_epoch { // The block is from a prior epoch, the voting source will be pulled-up. - node.unrealized_justified_checkpoint + node.unrealized_justified_checkpoint() // Sometimes we don't track the unrealized justification. In // that case, just use the fully-realized justified checkpoint. - .unwrap_or(node_justified_checkpoint) + .unwrap_or(*node_justified_checkpoint) } else { // The block is not from a prior epoch, therefore the voting source // is not pulled up. - node_justified_checkpoint + *node_justified_checkpoint }; let correct_justified = best_justified_checkpoint.epoch == genesis_epoch @@ -982,7 +1654,7 @@ impl ProtoArray { let correct_finalized = best_finalized_checkpoint.epoch == genesis_epoch || self - .is_finalized_checkpoint_or_descendant::<E>(node.root, best_finalized_checkpoint); + .is_finalized_checkpoint_or_descendant::<E>(node.root(), best_finalized_checkpoint); correct_justified && correct_finalized } @@ -1004,7 +1676,7 @@ impl ProtoArray { block_root: &Hash256, ) -> impl Iterator<Item = (Hash256, Slot)> + 'a { self.iter_nodes(block_root) - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) } /// Returns `true` if the `descendant_root` has an ancestor with `ancestor_root`. Always @@ -1025,8 +1697,8 @@ impl ProtoArray { .and_then(|ancestor_index| self.nodes.get(*ancestor_index)) .and_then(|ancestor| { self.iter_block_roots(&descendant_root) - .take_while(|(_root, slot)| *slot >= ancestor.slot) - .find(|(_root, slot)| *slot == ancestor.slot) + .take_while(|(_root, slot)| *slot >= ancestor.slot()) + .find(|(_root, slot)| *slot == ancestor.slot()) .map(|(root, _slot)| root == ancestor_root) }) .unwrap_or(false) @@ -1065,15 +1737,15 @@ impl ProtoArray { // Run this check once, outside of the loop rather than inside the loop. // If the conditions don't match for this node then they're unlikely to // start matching for its ancestors. - for checkpoint in &[node.finalized_checkpoint, node.justified_checkpoint] { - if checkpoint == &best_finalized_checkpoint { + for checkpoint in &[node.finalized_checkpoint(), node.justified_checkpoint()] { + if **checkpoint == best_finalized_checkpoint { return true; } } for checkpoint in &[ - node.unrealized_finalized_checkpoint, - node.unrealized_justified_checkpoint, + node.unrealized_finalized_checkpoint(), + node.unrealized_justified_checkpoint(), ] { if checkpoint.is_some_and(|cp| cp == best_finalized_checkpoint) { return true; @@ -1083,13 +1755,13 @@ impl ProtoArray { loop { // If `node` is less than or equal to the finalized slot then `node` // must be the finalized block. - if node.slot <= finalized_slot { - return node.root == finalized_root; + if node.slot() <= finalized_slot { + return node.root() == finalized_root; } // Since `node` is from a higher slot that the finalized checkpoint, // replace `node` with the parent of `node`. - if let Some(parent) = node.parent.and_then(|index| self.nodes.get(index)) { + if let Some(parent) = node.parent().and_then(|index| self.nodes.get(index)) { node = parent } else { // If `node` is not the finalized block and its parent does not @@ -1111,11 +1783,12 @@ impl ProtoArray { .iter() .rev() .find(|node| { - node.execution_status - .block_hash() + node.execution_status() + .ok() + .and_then(|execution_status| execution_status.block_hash()) .is_some_and(|node_block_hash| node_block_hash == *block_hash) }) - .map(|node| node.root) + .map(|node| node.root()) } /// Returns all nodes that have zero children and are descended from the finalized checkpoint. @@ -1129,13 +1802,17 @@ impl ProtoArray { ) -> Vec<&ProtoNode> { self.nodes .iter() - .filter(|node| { - node.best_child.is_none() + .enumerate() + .filter(|(i, node)| { + // TODO(gloas): we unoptimized this for Gloas fork choice, could re-optimize. + let num_children = self.nodes.iter().filter(|n| n.parent() == Some(*i)).count(); + num_children == 0 && self.is_finalized_checkpoint_or_descendant::<E>( - node.root, + node.root(), best_finalized_checkpoint, ) }) + .map(|(_, node)| node) .collect() } } @@ -1155,6 +1832,31 @@ pub fn calculate_committee_fraction<E: EthSpec>( .checked_div(100) } +/// Spec: `get_proposer_score`. +fn get_proposer_score<E: EthSpec>( + justified_balances: &JustifiedBalances, + spec: &ChainSpec, +) -> Result<u64, Error> { + let Some(proposer_score_boost) = spec.proposer_score_boost else { + return Ok(0); + }; + calculate_committee_fraction::<E>(justified_balances, proposer_score_boost) + .ok_or(Error::ProposerBoostOverflow(0)) +} + +/// Apply a signed delta to an unsigned weight, returning an error on overflow. +fn apply_delta(weight: u64, delta: i64, index: usize) -> Result<u64, Error> { + if delta < 0 { + weight + .checked_sub(delta.unsigned_abs()) + .ok_or(Error::DeltaOverflow(index)) + } else { + weight + .checked_add(delta as u64) + .ok_or(Error::DeltaOverflow(index)) + } +} + /// Reverse iterator over one path through a `ProtoArray`. pub struct Iter<'a> { next_node_index: Option<usize>, @@ -1167,7 +1869,7 @@ impl<'a> Iterator for Iter<'a> { fn next(&mut self) -> Option<Self::Item> { let next_node_index = self.next_node_index?; let node = self.proto_array.nodes.get(next_node_index)?; - self.next_node_index = node.parent; + self.next_node_index = node.parent(); Some(node) } } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index fad7d34615..4345115b17 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -2,7 +2,7 @@ use crate::{ JustifiedBalances, error::Error, proto_array::{ - InvalidationOperation, Iter, ProposerBoost, ProtoArray, ProtoNode, + InvalidationOperation, Iter, NodeDelta, ProtoArray, ProtoNode, ProposerBoost, calculate_committee_fraction, }, ssz_container::SszContainer, @@ -14,6 +14,7 @@ use ssz_derive::{Decode, Encode}; use std::{ collections::{BTreeSet, HashMap}, fmt, + time::Duration, }; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, @@ -24,12 +25,63 @@ pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; #[derive(Default, PartialEq, Clone, Encode, Decode)] pub struct VoteTracker { + current_root: Hash256, + next_root: Hash256, + current_slot: Slot, + next_slot: Slot, + current_payload_present: bool, + next_payload_present: bool, +} + +// Can be deleted once the V28 schema migration is buried. +// Matches the on-disk format from schema v28: current_root, next_root, next_epoch. +#[derive(Default, PartialEq, Clone, Encode, Decode)] +pub struct VoteTrackerV28 { current_root: Hash256, next_root: Hash256, next_epoch: Epoch, } -/// Represents the verification status of an execution payload. +// This impl is only used upon upgrade from pre-Gloas to Gloas with all pre-Gloas nodes. +// The payload status is `false` for pre-Gloas nodes. +impl From<VoteTrackerV28> for VoteTracker { + fn from(v: VoteTrackerV28) -> Self { + VoteTracker { + current_root: v.current_root, + next_root: v.next_root, + // The v28 format stored next_epoch rather than slots. Default to 0 since the + // vote tracker will be updated on the next attestation. + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, + } + } +} + +// This impl is only used upon downgrade from V29 to V28, with exclusively pre-Gloas nodes. +impl From<VoteTracker> for VoteTrackerV28 { + fn from(v: VoteTracker) -> Self { + // Drop the payload_present fields. This is safe because this is only called on pre-Gloas + // nodes. + VoteTrackerV28 { + current_root: v.current_root, + next_root: v.next_root, + // The v28 format stored next_epoch. Default to 0 since the vote tracker will be + // updated on the next attestation. + next_epoch: Epoch::new(0), + } + } +} + +/// Spec's `LatestMessage` type. Only used in tests. +pub struct LatestMessage { + pub slot: Slot, + pub root: Hash256, + pub payload_present: bool, +} + +/// Represents the verification status of an execution payload pre-Gloas. #[derive(Clone, Copy, Debug, PartialEq, Encode, Decode, Serialize, Deserialize)] #[ssz(enum_behaviour = "union")] pub enum ExecutionStatus { @@ -49,6 +101,33 @@ pub enum ExecutionStatus { Irrelevant(bool), } +/// Represents the status of an execution payload post-Gloas. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)] +#[ssz(enum_behaviour = "tag")] +#[repr(u8)] +pub enum PayloadStatus { + Empty = 0, + Full = 1, + Pending = 2, +} + +/// Spec's `ForkChoiceNode` augmented with ProtoNode index. +pub struct IndexedForkChoiceNode { + pub root: Hash256, + pub proto_node_index: usize, + pub payload_status: PayloadStatus, +} + +impl IndexedForkChoiceNode { + pub fn with_status(&self, payload_status: PayloadStatus) -> Self { + Self { + root: self.root, + proto_node_index: self.proto_node_index, + payload_status, + } + } +} + impl ExecutionStatus { pub fn is_execution_enabled(&self) -> bool { !matches!(self, ExecutionStatus::Irrelevant(_)) @@ -159,6 +238,11 @@ pub struct Block { pub execution_status: ExecutionStatus, pub unrealized_justified_checkpoint: Option<Checkpoint>, pub unrealized_finalized_checkpoint: Option<Checkpoint>, + + /// post-Gloas fields + pub execution_payload_parent_hash: Option<ExecutionBlockHash>, + pub execution_payload_block_hash: Option<ExecutionBlockHash>, + pub proposer_index: Option<u64>, } impl Block { @@ -423,6 +507,10 @@ impl ProtoArrayForkChoice { next_epoch_shuffling_id: AttestationShufflingId, unsatisfied_inclusion_list_blocks: HashMap<Slot, Hash256>, execution_status: ExecutionStatus, + execution_payload_parent_hash: Option<ExecutionBlockHash>, + execution_payload_block_hash: Option<ExecutionBlockHash>, + proposer_index: u64, + spec: &ChainSpec, ) -> Result<Self, String> { let mut proto_array = ProtoArray { prune_threshold: DEFAULT_PRUNE_THRESHOLD, @@ -447,14 +535,20 @@ impl ProtoArrayForkChoice { execution_status, unrealized_justified_checkpoint: Some(justified_checkpoint), unrealized_finalized_checkpoint: Some(finalized_checkpoint), + execution_payload_parent_hash, + execution_payload_block_hash, + proposer_index: Some(proposer_index), }; proto_array .on_block::<E>( block, current_slot, - justified_checkpoint, - finalized_checkpoint, + spec, + // Anchor block is always timely (delay=0 ensures both timeliness + // checks pass). Combined with `is_genesis` override in on_block, + // this matches spec's `block_timeliness = {anchor: [True, True]}`. + Duration::ZERO, ) .map_err(|e| format!("Failed to add finalized block to proto_array: {:?}", e))?; @@ -465,6 +559,18 @@ impl ProtoArrayForkChoice { }) } + /// Mark a Gloas payload envelope as valid and received. + /// + /// This must only be called for valid Gloas payloads. + pub fn on_valid_payload_envelope_received( + &mut self, + block_root: Hash256, + ) -> Result<(), String> { + self.proto_array + .on_valid_payload_envelope_received(block_root) + .map_err(|e| format!("Failed to process execution payload: {:?}", e)) + } + /// See `ProtoArray::propagate_execution_payload_validation` for documentation. pub fn process_execution_payload_validation( &mut self, @@ -490,36 +596,71 @@ impl ProtoArrayForkChoice { &mut self, validator_index: usize, block_root: Hash256, - target_epoch: Epoch, + attestation_slot: Slot, + payload_present: bool, ) -> Result<(), String> { let vote = self.votes.get_mut(validator_index); - if target_epoch > vote.next_epoch || *vote == VoteTracker::default() { + if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { vote.next_root = block_root; - vote.next_epoch = target_epoch; + vote.next_slot = attestation_slot; + vote.next_payload_present = payload_present; } Ok(()) } + /// Process a PTC vote by setting the appropriate bits on the target block's V29 node. + /// + /// `ptc_index` is the voter's position in the PTC committee (resolved by the caller). + /// This writes directly to the node's bitfields, bypassing the delta pipeline. + pub fn process_payload_attestation( + &mut self, + block_root: Hash256, + ptc_index: usize, + payload_present: bool, + blob_data_available: bool, + ) -> Result<(), String> { + let node_index = self + .proto_array + .indices + .get(&block_root) + .copied() + .ok_or_else(|| { + format!("process_payload_attestation: unknown block root {block_root:?}") + })?; + let node = self.proto_array.nodes.get_mut(node_index).ok_or_else(|| { + format!("process_payload_attestation: invalid node index {node_index}") + })?; + let v29 = node + .as_v29_mut() + .map_err(|_| format!("process_payload_attestation: node {block_root:?} is not V29"))?; + + v29.payload_timeliness_votes + .set(ptc_index, payload_present) + .map_err(|e| format!("process_payload_attestation: timeliness set failed: {e:?}"))?; + v29.payload_data_availability_votes + .set(ptc_index, blob_data_available) + .map_err(|e| { + format!("process_payload_attestation: data availability set failed: {e:?}") + })?; + + Ok(()) + } + pub fn process_block<E: EthSpec>( &mut self, block: Block, current_slot: Slot, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, + spec: &ChainSpec, + time_into_slot: Duration, ) -> Result<(), String> { if block.parent_root.is_none() { return Err("Missing parent root".to_string()); } self.proto_array - .on_block::<E>( - block, - current_slot, - justified_checkpoint, - finalized_checkpoint, - ) + .on_block::<E>(block, current_slot, spec, time_into_slot) .map_err(|e| format!("process_block_error: {:?}", e)) } @@ -533,12 +674,19 @@ impl ProtoArrayForkChoice { equivocating_indices: &BTreeSet<u64>, current_slot: Slot, spec: &ChainSpec, - ) -> Result<Hash256, String> { + ) -> Result<(Hash256, PayloadStatus), String> { let old_balances = &mut self.balances; let new_balances = justified_state_balances; + let node_slots = self + .proto_array + .nodes + .iter() + .map(|node| node.slot()) + .collect::<Vec<_>>(); let deltas = compute_deltas( &self.proto_array.indices, + &node_slots, &mut self.votes, &old_balances.effective_balances, &new_balances.effective_balances, @@ -547,15 +695,7 @@ impl ProtoArrayForkChoice { .map_err(|e| format!("find_head compute_deltas failed: {:?}", e))?; self.proto_array - .apply_score_changes::<E>( - deltas, - justified_checkpoint, - finalized_checkpoint, - new_balances, - proposer_boost_root, - current_slot, - spec, - ) + .apply_score_changes::<E>(deltas) .map_err(|e| format!("find_head apply_score_changes failed: {:?}", e))?; *old_balances = new_balances.clone(); @@ -566,6 +706,9 @@ impl ProtoArrayForkChoice { current_slot, justified_checkpoint, finalized_checkpoint, + proposer_boost_root, + new_balances, + spec, ) .map_err(|e| format!("find_head failed: {:?}", e)) } @@ -595,13 +738,13 @@ impl ProtoArrayForkChoice { )?; // Only re-org a single slot. This prevents cascading failures during asynchrony. - let head_slot_ok = info.head_node.slot + 1 == current_slot; + let head_slot_ok = info.head_node.slot().saturating_add(1_u64) == current_slot; if !head_slot_ok { return Err(DoNotReOrg::HeadDistance.into()); } // Only re-org if the head's weight is less than the heads configured committee fraction. - let head_weight = info.head_node.weight; + let head_weight = info.head_node.weight(); let re_org_head_weight_threshold = info.re_org_head_weight_threshold; let weak_head = head_weight < re_org_head_weight_threshold; if !weak_head { @@ -612,8 +755,10 @@ impl ProtoArrayForkChoice { .into()); } - // Only re-org if the parent's weight is greater than the parents configured committee fraction. - let parent_weight = info.parent_node.weight; + // Spec: `is_parent_strong`. Use payload-aware weight matching the + // payload path the head node is on from its parent. + let parent_payload_status = info.head_node.get_parent_payload_status(); + let parent_weight = info.parent_node.attestation_score(parent_payload_status); let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; let parent_strong = parent_weight > re_org_parent_weight_threshold; if !parent_strong { @@ -652,14 +797,14 @@ impl ProtoArrayForkChoice { let parent_node = nodes.pop().ok_or(DoNotReOrg::MissingHeadOrParentNode)?; let head_node = nodes.pop().ok_or(DoNotReOrg::MissingHeadOrParentNode)?; - let parent_slot = parent_node.slot; - let head_slot = head_node.slot; - let re_org_block_slot = head_slot + 1; + let parent_slot = parent_node.slot(); + let head_slot = head_node.slot(); + let re_org_block_slot = head_slot.saturating_add(1_u64); // Check finalization distance. let proposal_epoch = re_org_block_slot.epoch(E::slots_per_epoch()); let finalized_epoch = head_node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .ok_or(DoNotReOrg::MissingHeadFinalizedCheckpoint)? .epoch; let epochs_since_finalization = proposal_epoch.saturating_sub(finalized_epoch).as_u64(); @@ -691,10 +836,10 @@ impl ProtoArrayForkChoice { } // Check FFG. - let ffg_competitive = parent_node.unrealized_justified_checkpoint - == head_node.unrealized_justified_checkpoint - && parent_node.unrealized_finalized_checkpoint - == head_node.unrealized_finalized_checkpoint; + let ffg_competitive = parent_node.unrealized_justified_checkpoint() + == head_node.unrealized_justified_checkpoint() + && parent_node.unrealized_finalized_checkpoint() + == head_node.unrealized_finalized_checkpoint(); if !ffg_competitive { return Err(DoNotReOrg::JustificationAndFinalizationNotCompetitive.into()); } @@ -722,20 +867,17 @@ impl ProtoArrayForkChoice { /// This will operate on *all* blocks, even those that do not descend from the finalized /// ancestor. pub fn contains_invalid_payloads(&mut self) -> bool { - self.proto_array - .nodes - .iter() - .any(|node| node.execution_status.is_invalid()) + self.proto_array.nodes.iter().any(|node| { + node.execution_status() + .is_ok_and(|status| status.is_invalid()) + }) } /// For all nodes, regardless of their relationship to the finalized block, set their execution /// status to be optimistic. /// /// In practice this means forgetting any `VALID` or `INVALID` statuses. - pub fn set_all_blocks_to_optimistic<E: EthSpec>( - &mut self, - spec: &ChainSpec, - ) -> Result<(), String> { + pub fn set_all_blocks_to_optimistic<E: EthSpec>(&mut self) -> Result<(), String> { // Iterate backwards through all nodes in the `proto_array`. Whilst it's not strictly // required to do this process in reverse, it seems natural when we consider how LMD votes // are counted. @@ -750,19 +892,21 @@ impl ProtoArrayForkChoice { .get_mut(node_index) .ok_or("unreachable index out of bounds in proto_array nodes")?; - match node.execution_status { - ExecutionStatus::Invalid(block_hash) => { - node.execution_status = ExecutionStatus::Optimistic(block_hash); + match node.execution_status() { + Ok(ExecutionStatus::Invalid(block_hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Optimistic(block_hash); + } // Restore the weight of the node, it would have been set to `0` in // `apply_score_changes` when it was invalidated. - let mut restored_weight: u64 = self + let restored_weight: u64 = self .votes .0 .iter() .enumerate() .filter_map(|(validator_index, vote)| { - if vote.current_root == node.root { + if vote.current_root == node.root() { // Any voting validator that does not have a balance should be // ignored. This is consistent with `compute_deltas`. self.balances.effective_balances.get(validator_index) @@ -772,36 +916,16 @@ impl ProtoArrayForkChoice { }) .sum(); - // If the invalid root was boosted, apply the weight to it and - // ancestors. - if let Some(proposer_score_boost) = spec.proposer_score_boost - && self.proto_array.previous_proposer_boost.root == node.root - { - // Compute the score based upon the current balances. We can't rely on - // the `previous_proposr_boost.score` since it is set to zero with an - // invalid node. - let proposer_score = - calculate_committee_fraction::<E>(&self.balances, proposer_score_boost) - .ok_or("Failed to compute proposer boost")?; - // Store the score we've applied here so it can be removed in - // a later call to `apply_score_changes`. - self.proto_array.previous_proposer_boost.score = proposer_score; - // Apply this boost to this node. - restored_weight = restored_weight - .checked_add(proposer_score) - .ok_or("Overflow when adding boost to weight")?; - } - // Add the restored weight to the node and all ancestors. if restored_weight > 0 { let mut node_or_ancestor = node; loop { - node_or_ancestor.weight = node_or_ancestor - .weight + *node_or_ancestor.weight_mut() = node_or_ancestor + .weight() .checked_add(restored_weight) .ok_or("Overflow when adding weight to ancestor")?; - if let Some(parent_index) = node_or_ancestor.parent { + if let Some(parent_index) = node_or_ancestor.parent() { node_or_ancestor = self .proto_array .nodes @@ -817,11 +941,14 @@ impl ProtoArrayForkChoice { } // There are no balance changes required if the node was either valid or // optimistic. - ExecutionStatus::Valid(block_hash) | ExecutionStatus::Optimistic(block_hash) => { - node.execution_status = ExecutionStatus::Optimistic(block_hash) + Ok(ExecutionStatus::Valid(block_hash)) + | Ok(ExecutionStatus::Optimistic(block_hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Optimistic(block_hash) + } } // An irrelevant node cannot become optimistic, this is a no-op. - ExecutionStatus::Irrelevant(_) => (), + Ok(ExecutionStatus::Irrelevant(_)) | Err(_) => (), } } @@ -858,30 +985,94 @@ impl ProtoArrayForkChoice { pub fn get_block(&self, block_root: &Hash256) -> Option<Block> { let block = self.get_proto_node(block_root)?; let parent_root = block - .parent + .parent() .and_then(|i| self.proto_array.nodes.get(i)) - .map(|parent| parent.root); + .map(|parent| parent.root()); Some(Block { - slot: block.slot, - root: block.root, + slot: block.slot(), + root: block.root(), parent_root, - state_root: block.state_root, - target_root: block.target_root, - current_epoch_shuffling_id: block.current_epoch_shuffling_id.clone(), - next_epoch_shuffling_id: block.next_epoch_shuffling_id.clone(), - justified_checkpoint: block.justified_checkpoint, - finalized_checkpoint: block.finalized_checkpoint, - execution_status: block.execution_status, - unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + state_root: block.state_root(), + target_root: block.target_root(), + current_epoch_shuffling_id: block.current_epoch_shuffling_id().clone(), + next_epoch_shuffling_id: block.next_epoch_shuffling_id().clone(), + justified_checkpoint: *block.justified_checkpoint(), + finalized_checkpoint: *block.finalized_checkpoint(), + execution_status: block + .execution_status() + .unwrap_or_else(|_| ExecutionStatus::irrelevant()), + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint(), + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint(), + execution_payload_parent_hash: block.execution_payload_parent_hash().ok(), + execution_payload_block_hash: block.execution_payload_block_hash().ok(), + proposer_index: block.proposer_index().ok(), }) } + /// Returns whether the proposer should extend the parent's execution payload chain. + /// + /// This checks timeliness, data availability, and proposer boost conditions per the spec. + pub fn should_extend_payload<E: EthSpec>( + &self, + block_root: &Hash256, + proposer_boost_root: Hash256, + ) -> Result<bool, String> { + let block_index = self + .proto_array + .indices + .get(block_root) + .ok_or_else(|| format!("Unknown block root: {block_root:?}"))?; + let proto_node = self + .proto_array + .nodes + .get(*block_index) + .ok_or_else(|| format!("Missing node at index: {block_index}"))?; + let fc_node = IndexedForkChoiceNode { + root: proto_node.root(), + proto_node_index: *block_index, + payload_status: proto_node.get_parent_payload_status(), + }; + self.proto_array + .should_extend_payload::<E>(&fc_node, proto_node, proposer_boost_root) + .map_err(|e| format!("{e:?}")) + } + /// Returns the `block.execution_status` field, if the block is present. pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option<ExecutionStatus> { let block = self.get_proto_node(block_root)?; - Some(block.execution_status) + Some( + block + .execution_status() + .unwrap_or_else(|_| ExecutionStatus::irrelevant()), + ) + } + + /// Returns whether the execution payload for a block has been received. + /// + /// Returns `false` for pre-Gloas (V17) nodes or unknown blocks. + pub fn is_payload_received(&self, block_root: &Hash256) -> bool { + self.get_proto_node(block_root) + .and_then(|node| node.payload_received().ok()) + .unwrap_or(false) + } + + /// Returns the canonical payload status of a block, matching the decision + /// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`. + pub fn get_canonical_payload_status<E: EthSpec>( + &self, + block_root: &Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + spec: &ChainSpec, + ) -> Result<PayloadStatus, Error> { + self.proto_array.get_canonical_payload_status::<E>( + *block_root, + current_slot, + proposer_boost_root, + &self.balances, + spec, + ) } /// Returns the weight of a given block. @@ -890,9 +1081,11 @@ impl ProtoArrayForkChoice { self.proto_array .nodes .get(*block_index) - .map(|node| node.weight) + .map(|node| node.weight()) } + /// Returns the payload status of the head node based on accumulated weights and tiebreaker. + /// /// See `ProtoArray` documentation. pub fn is_descendant(&self, ancestor_root: Hash256, descendant_root: Hash256) -> bool { self.proto_array @@ -909,14 +1102,17 @@ impl ProtoArrayForkChoice { .is_finalized_checkpoint_or_descendant::<E>(descendant_root, best_finalized_checkpoint) } - pub fn latest_message(&self, validator_index: usize) -> Option<(Hash256, Epoch)> { - if validator_index < self.votes.0.len() { - let vote = &self.votes.0[validator_index]; - + /// NOTE: only used in tests. + pub fn latest_message(&self, validator_index: usize) -> Option<LatestMessage> { + if let Some(vote) = self.votes.0.get(validator_index) { if *vote == VoteTracker::default() { None } else { - Some((vote.next_root, vote.next_epoch)) + Some(LatestMessage { + root: vote.next_root, + slot: vote.next_slot, + payload_present: vote.next_payload_present, + }) } } else { None @@ -936,21 +1132,12 @@ impl ProtoArrayForkChoice { self.proto_array.iter_block_roots(block_root) } - pub fn as_ssz_container( - &self, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, - ) -> SszContainer { - SszContainer::from_proto_array(self, justified_checkpoint, finalized_checkpoint) + pub fn as_ssz_container(&self) -> SszContainer { + SszContainer::from_proto_array(self) } - pub fn as_bytes( - &self, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, - ) -> Vec<u8> { - self.as_ssz_container(justified_checkpoint, finalized_checkpoint) - .as_ssz_bytes() + pub fn as_bytes(&self) -> Vec<u8> { + self.as_ssz_container().as_ssz_bytes() } pub fn from_bytes(bytes: &[u8], balances: JustifiedBalances) -> Result<Self, String> { @@ -1004,12 +1191,28 @@ impl ProtoArrayForkChoice { /// always valid). fn compute_deltas( indices: &HashMap<Hash256, usize>, + node_slots: &[Slot], votes: &mut ElasticList<VoteTracker>, old_balances: &[u64], new_balances: &[u64], equivocating_indices: &BTreeSet<u64>, -) -> Result<Vec<i64>, Error> { - let mut deltas = vec![0_i64; indices.len()]; +) -> Result<Vec<NodeDelta>, Error> { + let block_slot = |index: usize| -> Result<Slot, Error> { + node_slots + .get(index) + .copied() + .ok_or(Error::InvalidNodeDelta(index)) + }; + + let mut deltas = vec![ + NodeDelta { + delta: 0, + empty_delta: 0, + full_delta: 0, + equivocating_attestation_delta: 0, + }; + indices.len() + ]; for (val_index, vote) in votes.iter_mut().enumerate() { // There is no need to create a score change if the validator has never voted or both their @@ -1034,17 +1237,30 @@ fn compute_deltas( let old_balance = old_balances.get(val_index).copied().unwrap_or(0); if let Some(current_delta_index) = indices.get(&vote.current_root).copied() { - let delta = deltas - .get(current_delta_index) - .ok_or(Error::InvalidNodeDelta(current_delta_index))? + let node_delta = deltas + .get_mut(current_delta_index) + .ok_or(Error::InvalidNodeDelta(current_delta_index))?; + node_delta.delta = node_delta + .delta .checked_sub(old_balance as i64) .ok_or(Error::DeltaOverflow(current_delta_index))?; - // Array access safe due to check on previous line. - deltas[current_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.current_slot, + vote.current_payload_present, + block_slot(current_delta_index)?, + ); + node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; + + // Track equivocating weight for `is_head_weak` monotonicity. + node_delta.equivocating_attestation_delta = node_delta + .equivocating_attestation_delta + .saturating_add(old_balance); } vote.current_root = Hash256::zero(); + vote.current_slot = Slot::new(0); + vote.current_payload_present = false; } // We've handled this slashed validator, continue without applying an ordinary delta. continue; @@ -1061,34 +1277,52 @@ fn compute_deltas( // on-boarded less validators than the prior fork. let new_balance = new_balances.get(val_index).copied().unwrap_or(0); - if vote.current_root != vote.next_root || old_balance != new_balance { + if vote.current_root != vote.next_root + || old_balance != new_balance + || vote.current_payload_present != vote.next_payload_present + || vote.current_slot != vote.next_slot + { // We ignore the vote if it is not known in `indices`. We assume that it is outside // of our tree (i.e., pre-finalization) and therefore not interesting. if let Some(current_delta_index) = indices.get(&vote.current_root).copied() { - let delta = deltas - .get(current_delta_index) - .ok_or(Error::InvalidNodeDelta(current_delta_index))? + let node_delta = deltas + .get_mut(current_delta_index) + .ok_or(Error::InvalidNodeDelta(current_delta_index))?; + node_delta.delta = node_delta + .delta .checked_sub(old_balance as i64) .ok_or(Error::DeltaOverflow(current_delta_index))?; - // Array access safe due to check on previous line. - deltas[current_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.current_slot, + vote.current_payload_present, + block_slot(current_delta_index)?, + ); + node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; } // We ignore the vote if it is not known in `indices`. We assume that it is outside // of our tree (i.e., pre-finalization) and therefore not interesting. if let Some(next_delta_index) = indices.get(&vote.next_root).copied() { - let delta = deltas - .get(next_delta_index) - .ok_or(Error::InvalidNodeDelta(next_delta_index))? + let node_delta = deltas + .get_mut(next_delta_index) + .ok_or(Error::InvalidNodeDelta(next_delta_index))?; + node_delta.delta = node_delta + .delta .checked_add(new_balance as i64) .ok_or(Error::DeltaOverflow(next_delta_index))?; - // Array access safe due to check on previous line. - deltas[next_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.next_slot, + vote.next_payload_present, + block_slot(next_delta_index)?, + ); + node_delta.add_payload_delta(status, new_balance, next_delta_index)?; } vote.current_root = vote.next_root; + vote.current_slot = vote.next_slot; + vote.current_payload_present = vote.next_payload_present; } } @@ -1106,8 +1340,13 @@ mod test_compute_deltas { Hash256::from_low_u64_be(i as u64 + 1) } + fn test_node_slots(count: usize) -> Vec<Slot> { + vec![Slot::new(0); count] + } + #[test] fn finalized_descendant() { + let spec = MainnetEthSpec::default_spec(); let genesis_slot = Slot::new(0); let genesis_epoch = Epoch::new(0); @@ -1139,6 +1378,10 @@ mod test_compute_deltas { junk_shuffling_id.clone(), HashMap::new(), execution_status, + None, + None, + 0, + &spec, ) .unwrap(); @@ -1155,13 +1398,16 @@ mod test_compute_deltas { next_epoch_shuffling_id: junk_shuffling_id.clone(), justified_checkpoint: genesis_checkpoint, finalized_checkpoint: genesis_checkpoint, - execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), + execution_status, unrealized_finalized_checkpoint: Some(genesis_checkpoint), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + proposer_index: Some(0), }, genesis_slot + 1, - genesis_checkpoint, - genesis_checkpoint, + &spec, + Duration::ZERO, ) .unwrap(); @@ -1183,10 +1429,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + proposer_index: Some(0), }, genesis_slot + 1, - genesis_checkpoint, - genesis_checkpoint, + &spec, + Duration::ZERO, ) .unwrap(); @@ -1262,6 +1511,7 @@ mod test_compute_deltas { /// *checkpoint*, not just the finalized *block*. #[test] fn finalized_descendant_edge_case() { + let spec = MainnetEthSpec::default_spec(); let get_block_root = Hash256::from_low_u64_be; let genesis_slot = Slot::new(0); let junk_state_root = Hash256::zero(); @@ -1284,6 +1534,10 @@ mod test_compute_deltas { junk_shuffling_id.clone(), HashMap::new(), execution_status, + None, + None, + 0, + &spec, ) .unwrap(); @@ -1312,10 +1566,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), unrealized_finalized_checkpoint: Some(genesis_checkpoint), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + proposer_index: Some(0), }, Slot::from(block.slot), - genesis_checkpoint, - genesis_checkpoint, + &spec, + Duration::ZERO, ) .unwrap(); }; @@ -1418,7 +1675,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: Hash256::zero(), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(0); new_balances.push(0); @@ -1426,6 +1686,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1469,7 +1730,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: hash_from_index(0), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1477,6 +1741,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1527,7 +1792,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: hash_from_index(i), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1535,6 +1803,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1580,7 +1849,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(0), next_root: hash_from_index(1), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1588,6 +1860,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1644,18 +1917,25 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: Hash256::zero(), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); // One validator moves their vote from the block to something outside the tree. votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: Hash256::from_low_u64_be(1337), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1697,7 +1977,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(0), next_root: hash_from_index(1), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(OLD_BALANCE); new_balances.push(NEW_BALANCE); @@ -1705,6 +1988,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1766,12 +2050,16 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1822,12 +2110,16 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1876,7 +2168,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } @@ -1885,6 +2180,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1914,6 +2210,7 @@ mod test_compute_deltas { // Re-computing the deltas should be a no-op (no repeat deduction for the slashed validator). let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &new_balances, &new_balances, @@ -1922,4 +2219,68 @@ mod test_compute_deltas { .expect("should compute deltas"); assert_eq!(deltas, vec![0, 0]); } + + #[test] + fn payload_bucket_changes_on_non_pending_vote() { + const BALANCE: u64 = 42; + + let mut indices = HashMap::new(); + indices.insert(hash_from_index(1), 0); + + let node_slots = vec![Slot::new(0)]; + let mut votes = ElasticList(vec![VoteTracker { + current_root: hash_from_index(1), + next_root: hash_from_index(1), + current_slot: Slot::new(1), + next_slot: Slot::new(1), + current_payload_present: false, + next_payload_present: true, + }]); + + let deltas = compute_deltas( + &indices, + &node_slots, + &mut votes, + &[BALANCE], + &[BALANCE], + &BTreeSet::new(), + ) + .expect("should compute deltas"); + + assert_eq!(deltas[0].delta, 0); + assert_eq!(deltas[0].empty_delta, -(BALANCE as i64)); + assert_eq!(deltas[0].full_delta, BALANCE as i64); + } + + #[test] + fn pending_vote_only_updates_regular_weight() { + const BALANCE: u64 = 42; + + let mut indices = HashMap::new(); + indices.insert(hash_from_index(1), 0); + + let node_slots = vec![Slot::new(0)]; + let mut votes = ElasticList(vec![VoteTracker { + current_root: hash_from_index(1), + next_root: hash_from_index(1), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: true, + }]); + + let deltas = compute_deltas( + &indices, + &node_slots, + &mut votes, + &[BALANCE], + &[BALANCE], + &BTreeSet::new(), + ) + .expect("should compute deltas"); + + assert_eq!(deltas[0].delta, 0); + assert_eq!(deltas[0].empty_delta, 0); + assert_eq!(deltas[0].full_delta, 0); + } } diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index 63badf2112..6cc3becec2 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -1,8 +1,8 @@ use crate::proto_array::ProposerBoost; use crate::{ Error, JustifiedBalances, - proto_array::{ProtoArray, ProtoNodeV17}, - proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker}, + proto_array::{ProtoArray, ProtoNode, ProtoNodeV17}, + proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker, VoteTrackerV28}, }; use ssz::{Encode, four_byte_option_impl}; use ssz_derive::{Decode, Encode}; @@ -14,44 +14,44 @@ use types::{Checkpoint, Hash256, Slot}; // selector. four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); -pub type SszContainer = SszContainerV28; +pub type SszContainer = SszContainerV29; #[superstruct( - variants(V17, V28), + variants(V28, V29), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] pub struct SszContainer { + #[superstruct(only(V28))] + pub votes_v28: Vec<VoteTrackerV28>, + #[superstruct(only(V29))] pub votes: Vec<VoteTracker>, - #[superstruct(only(V17))] - pub balances: Vec<u64>, pub prune_threshold: usize, // Deprecated, remove in a future schema migration + #[superstruct(only(V28))] justified_checkpoint: Checkpoint, // Deprecated, remove in a future schema migration + #[superstruct(only(V28))] finalized_checkpoint: Checkpoint, + #[superstruct(only(V28))] pub nodes: Vec<ProtoNodeV17>, + #[superstruct(only(V29))] + pub nodes: Vec<ProtoNode>, pub indices: Vec<(Hash256, usize)>, + #[superstruct(only(V28))] pub previous_proposer_boost: ProposerBoost, pub unsatisfied_inclusion_list_blocks: Vec<(Slot, Hash256)>, } -impl SszContainer { - pub fn from_proto_array( - from: &ProtoArrayForkChoice, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, - ) -> Self { +impl SszContainerV29 { + pub fn from_proto_array(from: &ProtoArrayForkChoice) -> Self { let proto_array = &from.proto_array; Self { votes: from.votes.0.clone(), prune_threshold: proto_array.prune_threshold, - justified_checkpoint, - finalized_checkpoint, nodes: proto_array.nodes.clone(), indices: proto_array.indices.iter().map(|(k, v)| (*k, *v)).collect(), - previous_proposer_boost: proto_array.previous_proposer_boost, unsatisfied_inclusion_list_blocks: proto_array .unsatisfied_inclusion_list_blocks .iter() @@ -61,15 +61,15 @@ impl SszContainer { } } -impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { +impl TryFrom<(SszContainerV29, JustifiedBalances)> for ProtoArrayForkChoice { type Error = Error; - fn try_from((from, balances): (SszContainer, JustifiedBalances)) -> Result<Self, Error> { + fn try_from((from, balances): (SszContainerV29, JustifiedBalances)) -> Result<Self, Error> { let proto_array = ProtoArray { prune_threshold: from.prune_threshold, nodes: from.nodes, indices: from.indices.into_iter().collect::<HashMap<_, _>>(), - previous_proposer_boost: from.previous_proposer_boost, + previous_proposer_boost: ProposerBoost::default(), unsatisfied_inclusion_list_blocks: from .unsatisfied_inclusion_list_blocks .into_iter() @@ -84,35 +84,51 @@ impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { } } -// Convert V17 to V28 by dropping balances. -impl From<SszContainerV17> for SszContainerV28 { - fn from(v17: SszContainerV17) -> Self { +// Convert legacy V28 to current V29. +impl From<SszContainerV28> for SszContainerV29 { + fn from(v28: SszContainerV28) -> Self { Self { - votes: v17.votes, - prune_threshold: v17.prune_threshold, - justified_checkpoint: v17.justified_checkpoint, - finalized_checkpoint: v17.finalized_checkpoint, - nodes: v17.nodes, - indices: v17.indices, - previous_proposer_boost: v17.previous_proposer_boost, - unsatisfied_inclusion_list_blocks: v17.unsatisfied_inclusion_list_blocks, + votes: v28.votes_v28.into_iter().map(Into::into).collect(), + prune_threshold: v28.prune_threshold, + nodes: v28 + .nodes + .into_iter() + .map(|mut node| { + // best_child/best_descendant are no longer used (replaced by + // the virtual tree walk). Clear during conversion. + node.best_child = None; + node.best_descendant = None; + ProtoNode::V17(node) + }) + .collect(), + indices: v28.indices, + unsatisfied_inclusion_list_blocks: vec![], } } } -// Convert V28 to V17 by re-adding balances. -impl From<(SszContainerV28, JustifiedBalances)> for SszContainerV17 { - fn from((v28, balances): (SszContainerV28, JustifiedBalances)) -> Self { +// Downgrade current V29 to legacy V28 (lossy: V29 nodes lose payload-specific fields). +impl From<SszContainerV29> for SszContainerV28 { + fn from(v29: SszContainerV29) -> Self { Self { - votes: v28.votes, - balances: balances.effective_balances.clone(), - prune_threshold: v28.prune_threshold, - justified_checkpoint: v28.justified_checkpoint, - finalized_checkpoint: v28.finalized_checkpoint, - nodes: v28.nodes, - indices: v28.indices, - previous_proposer_boost: v28.previous_proposer_boost, - unsatisfied_inclusion_list_blocks: v28.unsatisfied_inclusion_list_blocks, + votes_v28: v29.votes.into_iter().map(Into::into).collect(), + prune_threshold: v29.prune_threshold, + // These checkpoints are not consumed in v28 paths since the upgrade from v17, + // we can safely default the values. + justified_checkpoint: Checkpoint::default(), + finalized_checkpoint: Checkpoint::default(), + nodes: v29 + .nodes + .into_iter() + .filter_map(|node| match node { + ProtoNode::V17(v17) => Some(v17), + ProtoNode::V29(_) => None, + }) + .collect(), + indices: v29.indices, + // Proposer boost is not tracked in V29 (computed on-the-fly), so reset it. + previous_proposer_boost: ProposerBoost::default(), + unsatisfied_inclusion_list_blocks: v29.unsatisfied_inclusion_list_blocks, } } } diff --git a/consensus/state_processing/Cargo.toml b/consensus/state_processing/Cargo.toml index a08035d583..ae0af03231 100644 --- a/consensus/state_processing/Cargo.toml +++ b/consensus/state_processing/Cargo.toml @@ -5,11 +5,12 @@ authors = ["Paul Hauner <paul@paulhauner.com>", "Michael Sproul <michael@sigmapr edition = { workspace = true } [features] -default = ["legacy-arith"] +default = [] fake_crypto = ["bls/fake_crypto"] -legacy-arith = ["types/legacy-arith"] -arbitrary-fuzz = [ - "types/arbitrary-fuzz", +arbitrary = [ + "dep:arbitrary", + "smallvec/arbitrary", + "types/arbitrary", "merkle_proof/arbitrary", "ethereum_ssz/arbitrary", "ssz_types/arbitrary", @@ -18,7 +19,7 @@ arbitrary-fuzz = [ portable = ["bls/supranational-portable"] [dependencies] -arbitrary = { workspace = true } +arbitrary = { workspace = true, optional = true } bls = { workspace = true } educe = { workspace = true } ethereum_hashing = { workspace = true } diff --git a/consensus/state_processing/src/common/altair.rs b/consensus/state_processing/src/common/altair.rs index 4380154133..dae6b0302d 100644 --- a/consensus/state_processing/src/common/altair.rs +++ b/consensus/state_processing/src/common/altair.rs @@ -28,7 +28,7 @@ pub fn get_base_reward( validator_effective_balance: u64, base_reward_per_increment: BaseRewardPerIncrement, spec: &ChainSpec, -) -> Result<u64, Error> { +) -> Result<u64, BeaconStateError> { validator_effective_balance .safe_div(spec.effective_balance_increment)? .safe_mul(base_reward_per_increment.as_u64()) diff --git a/consensus/state_processing/src/common/get_attestation_participation.rs b/consensus/state_processing/src/common/get_attestation_participation.rs index 71bf6329f1..2262b59ac1 100644 --- a/consensus/state_processing/src/common/get_attestation_participation.rs +++ b/consensus/state_processing/src/common/get_attestation_participation.rs @@ -1,8 +1,8 @@ use integer_sqrt::IntegerSquareRoot; +use safe_arith::SafeArith; use smallvec::SmallVec; -use types::{AttestationData, BeaconState, ChainSpec, EthSpec}; use types::{ - BeaconStateError as Error, + AttestationData, BeaconState, BeaconStateError as Error, ChainSpec, EthSpec, consts::altair::{ NUM_FLAG_INDICES, TIMELY_HEAD_FLAG_INDEX, TIMELY_SOURCE_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX, @@ -16,6 +16,8 @@ use types::{ /// /// This function will return an error if the source of the attestation doesn't match the /// state's relevant justified checkpoint. +/// +/// This function has been abstracted to work for all forks from Altair to Gloas. pub fn get_attestation_participation_flag_indices<E: EthSpec>( state: &BeaconState<E>, data: &AttestationData, @@ -27,13 +29,43 @@ pub fn get_attestation_participation_flag_indices<E: EthSpec>( } else { state.previous_justified_checkpoint() }; - - // Matching roots. let is_matching_source = data.source == justified_checkpoint; + + // Matching target. let is_matching_target = is_matching_source && data.target.root == *state.get_block_root_at_epoch(data.target.epoch)?; - let is_matching_head = - is_matching_target && data.beacon_block_root == *state.get_block_root(data.slot)?; + + // [New in Gloas:EIP7732] + let payload_matches = if state.fork_name_unchecked().gloas_enabled() { + if state.is_attestation_same_slot(data)? { + // For same-slot attestations, data.index must be 0 + if data.index != 0 { + return Err(Error::BadOverloadedDataIndex(data.index)); + } + true + } else { + // For non same-slot attestations, check execution payload availability + let slot_index = data + .slot + .as_usize() + .safe_rem(E::slots_per_historical_root())?; + let payload_index = state + .execution_payload_availability()? + .get(slot_index) + .map(|avail| if avail { 1 } else { 0 }) + .map_err(|_| Error::InvalidExecutionPayloadAvailabilityIndex(slot_index))?; + data.index == payload_index + } + } else { + // Essentially `payload_matches` is always true pre-Gloas (it is not considered for matching + // head). + true + }; + + // Matching head. + let is_matching_head = is_matching_target + && data.beacon_block_root == *state.get_block_root(data.slot)? + && payload_matches; if !is_matching_source { return Err(Error::IncorrectAttestationSource); diff --git a/consensus/state_processing/src/common/get_attesting_indices.rs b/consensus/state_processing/src/common/get_attesting_indices.rs index dc7be7c251..ff9f60c5ea 100644 --- a/consensus/state_processing/src/common/get_attesting_indices.rs +++ b/consensus/state_processing/src/common/get_attesting_indices.rs @@ -107,7 +107,7 @@ pub mod attesting_indices_electra { for committee_index in committee_indices { let beacon_committee = committees .get(committee_index as usize) - .ok_or(Error::NoCommitteeFound(committee_index))?; + .ok_or(BeaconStateError::NoCommitteeFound(committee_index))?; // This check is new to the spec's `process_attestation` in Electra. if committee_index >= committee_count_per_slot { diff --git a/consensus/state_processing/src/common/get_payload_attesting_indices.rs b/consensus/state_processing/src/common/get_payload_attesting_indices.rs new file mode 100644 index 0000000000..407e4f1372 --- /dev/null +++ b/consensus/state_processing/src/common/get_payload_attesting_indices.rs @@ -0,0 +1,42 @@ +use crate::per_block_processing::errors::{ + BlockOperationError, PayloadAttestationInvalid as Invalid, +}; +use ssz_types::VariableList; +use types::{ + BeaconState, BeaconStateError, ChainSpec, EthSpec, IndexedPayloadAttestation, + PayloadAttestation, +}; + +pub fn get_indexed_payload_attestation<E: EthSpec>( + state: &BeaconState<E>, + payload_attestation: &PayloadAttestation<E>, + spec: &ChainSpec, +) -> Result<IndexedPayloadAttestation<E>, BlockOperationError<Invalid>> { + let attesting_indices = get_payload_attesting_indices(state, payload_attestation, spec)?; + + Ok(IndexedPayloadAttestation { + attesting_indices: VariableList::new(attesting_indices)?, + data: payload_attestation.data.clone(), + signature: payload_attestation.signature.clone(), + }) +} + +pub fn get_payload_attesting_indices<E: EthSpec>( + state: &BeaconState<E>, + payload_attestation: &PayloadAttestation<E>, + spec: &ChainSpec, +) -> Result<Vec<u64>, BeaconStateError> { + let slot = payload_attestation.data.slot; + let ptc = state.get_ptc(slot, spec)?; + let bits = &payload_attestation.aggregation_bits; + + let mut attesting_indices = vec![]; + for (i, index) in ptc.into_iter().enumerate() { + if let Ok(true) = bits.get(i) { + attesting_indices.push(index as u64); + } + } + attesting_indices.sort_unstable(); + + Ok(attesting_indices) +} diff --git a/consensus/state_processing/src/common/mod.rs b/consensus/state_processing/src/common/mod.rs index 0287748fd0..e550a6c48b 100644 --- a/consensus/state_processing/src/common/mod.rs +++ b/consensus/state_processing/src/common/mod.rs @@ -1,6 +1,7 @@ mod deposit_data_tree; mod get_attestation_participation; mod get_attesting_indices; +mod get_payload_attesting_indices; mod initiate_validator_exit; mod slash_validator; @@ -13,6 +14,9 @@ pub use get_attestation_participation::get_attestation_participation_flag_indice pub use get_attesting_indices::{ attesting_indices_base, attesting_indices_electra, get_attesting_indices_from_state, }; +pub use get_payload_attesting_indices::{ + get_indexed_payload_attestation, get_payload_attesting_indices, +}; pub use initiate_validator_exit::initiate_validator_exit; pub use slash_validator::slash_validator; diff --git a/consensus/state_processing/src/common/slash_validator.rs b/consensus/state_processing/src/common/slash_validator.rs index 01c1855fb1..ac7379abd9 100644 --- a/consensus/state_processing/src/common/slash_validator.rs +++ b/consensus/state_processing/src/common/slash_validator.rs @@ -42,8 +42,7 @@ pub fn slash_validator<E: EthSpec>( decrease_balance( state, slashed_index, - validator_effective_balance - .safe_div(spec.min_slashing_penalty_quotient_for_state(state))?, + validator_effective_balance.safe_div(state.get_min_slashing_penalty_quotient(spec))?, )?; update_progressive_balances_on_slashing(state, slashed_index, validator_effective_balance)?; @@ -54,8 +53,8 @@ pub fn slash_validator<E: EthSpec>( // Apply proposer and whistleblower rewards let proposer_index = ctxt.get_proposer_index(state, spec)? as usize; let whistleblower_index = opt_whistleblower_index.unwrap_or(proposer_index); - let whistleblower_reward = validator_effective_balance - .safe_div(spec.whistleblower_reward_quotient_for_state(state))?; + let whistleblower_reward = + validator_effective_balance.safe_div(state.get_whistleblower_reward_quotient(spec))?; let proposer_reward = if state.fork_name_unchecked().altair_enabled() { whistleblower_reward .safe_mul(PROPOSER_WEIGHT)? diff --git a/consensus/state_processing/src/consensus_context.rs b/consensus/state_processing/src/consensus_context.rs index 07d554e303..bc7bd20384 100644 --- a/consensus/state_processing/src/consensus_context.rs +++ b/consensus/state_processing/src/consensus_context.rs @@ -1,11 +1,16 @@ use crate::EpochCacheError; -use crate::common::{attesting_indices_base, attesting_indices_electra}; -use crate::per_block_processing::errors::{AttestationInvalid, BlockOperationError}; +use crate::common::{ + attesting_indices_base, attesting_indices_electra, get_indexed_payload_attestation, +}; +use crate::per_block_processing::errors::{ + AttestationInvalid, BlockOperationError, PayloadAttestationInvalid, +}; use std::collections::{HashMap, hash_map::Entry}; use tree_hash::TreeHash; use types::{ AbstractExecPayload, AttestationRef, BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, - Hash256, IndexedAttestation, IndexedAttestationRef, SignedBeaconBlock, Slot, + Hash256, IndexedAttestation, IndexedAttestationRef, IndexedPayloadAttestation, + PayloadAttestation, SignedBeaconBlock, Slot, }; #[derive(Debug, PartialEq, Clone)] @@ -22,6 +27,8 @@ pub struct ConsensusContext<E: EthSpec> { pub current_block_root: Option<Hash256>, /// Cache of indexed attestations constructed during block processing. pub indexed_attestations: HashMap<Hash256, IndexedAttestation<E>>, + /// Cache of indexed payload attestations constructed during block processing. + pub indexed_payload_attestations: HashMap<Hash256, IndexedPayloadAttestation<E>>, } #[derive(Debug, PartialEq, Clone)] @@ -55,6 +62,7 @@ impl<E: EthSpec> ConsensusContext<E> { proposer_index: None, current_block_root: None, indexed_attestations: HashMap::new(), + indexed_payload_attestations: HashMap::new(), } } @@ -177,6 +185,24 @@ impl<E: EthSpec> ConsensusContext<E> { .map(|indexed_attestation| (*indexed_attestation).to_ref()) } + pub fn get_indexed_payload_attestation<'a>( + &'a mut self, + state: &BeaconState<E>, + payload_attestation: &'a PayloadAttestation<E>, + spec: &ChainSpec, + ) -> Result<&'a IndexedPayloadAttestation<E>, BlockOperationError<PayloadAttestationInvalid>> + { + let key = payload_attestation.tree_hash_root(); + match self.indexed_payload_attestations.entry(key) { + Entry::Occupied(occupied) => Ok(occupied.into_mut()), + Entry::Vacant(vacant) => { + let indexed_payload_attestation = + get_indexed_payload_attestation(state, payload_attestation, spec)?; + Ok(vacant.insert(indexed_payload_attestation)) + } + } + } + pub fn num_cached_indexed_attestations(&self) -> usize { self.indexed_attestations.len() } diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs new file mode 100644 index 0000000000..3da4d1e9d6 --- /dev/null +++ b/consensus/state_processing/src/envelope_processing.rs @@ -0,0 +1,234 @@ +use crate::VerifySignatures; +use crate::per_block_processing::compute_timestamp_at_slot; +use safe_arith::ArithError; +use tree_hash::TreeHash; +use types::{ + BeaconState, BeaconStateError, BuilderIndex, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, + SignedExecutionPayloadEnvelope, Slot, +}; + +macro_rules! envelope_verify { + ($condition: expr, $result: expr) => { + if !$condition { + return Err($result); + } + }; +} + +#[derive(Debug, Clone)] +pub enum EnvelopeProcessingError { + /// Bad Signature + BadSignature, + BeaconStateError(BeaconStateError), + ArithError(ArithError), + /// Envelope doesn't match latest beacon block header + LatestBlockHeaderMismatch { + envelope_root: Hash256, + block_header_root: Hash256, + }, + /// Envelope's `parent_beacon_block_root` doesn't match the parent root of the latest + /// block header. + ParentBeaconBlockRootMismatch { + envelope: Hash256, + state: Hash256, + }, + /// Envelope doesn't match latest beacon block slot + SlotMismatch { + envelope_slot: Slot, + parent_state_slot: Slot, + }, + /// The payload withdrawals don't match the state's payload withdrawals. + WithdrawalsRootMismatch { + state: Hash256, + payload: Hash256, + }, + // The builder index doesn't match the committed bid. + BuilderIndexMismatch { + committed_bid: BuilderIndex, + envelope: BuilderIndex, + }, + // The gas limit doesn't match the committed bid + GasLimitMismatch { + committed_bid: u64, + envelope: u64, + }, + // The block hash doesn't match the committed bid + BlockHashMismatch { + committed_bid: ExecutionBlockHash, + envelope: ExecutionBlockHash, + }, + // The parent hash doesn't match the previous execution payload + ParentHashMismatch { + state: ExecutionBlockHash, + envelope: ExecutionBlockHash, + }, + // The previous randao didn't match the payload + PrevRandaoMismatch { + committed_bid: Hash256, + envelope: Hash256, + }, + // The timestamp didn't match the payload + TimestampMismatch { + state: u64, + envelope: u64, + }, + // The execution requests root doesn't match the committed bid + ExecutionRequestsRootMismatch { + committed_bid: Hash256, + envelope: Hash256, + }, + /// The envelope was deemed invalid by the execution engine. + ExecutionInvalid, +} + +impl From<BeaconStateError> for EnvelopeProcessingError { + fn from(e: BeaconStateError) -> Self { + EnvelopeProcessingError::BeaconStateError(e) + } +} + +impl From<ArithError> for EnvelopeProcessingError { + fn from(e: ArithError) -> Self { + EnvelopeProcessingError::ArithError(e) + } +} + +/// Verifies a `SignedExecutionPayloadEnvelope` against the beacon state. +/// +/// This function performs pure verification with no state mutation. The execution requests +/// from the envelope are deferred to be processed in the next block via +/// `process_parent_execution_payload`. +/// +/// `block_state_root` should be the post-block state root (used to fill in the block header +/// for beacon_block_root verification). If `None`, the latest_block_header must already have +/// its state_root filled in. +pub fn verify_execution_payload_envelope<E: EthSpec>( + state: &BeaconState<E>, + signed_envelope: &SignedExecutionPayloadEnvelope<E>, + verify_signatures: VerifySignatures, + block_state_root: Hash256, + spec: &ChainSpec, +) -> Result<(), EnvelopeProcessingError> { + if verify_signatures.is_true() && !signed_envelope.verify_signature_with_state(state, spec)? { + return Err(EnvelopeProcessingError::BadSignature); + } + + let envelope = &signed_envelope.message; + let payload = &envelope.payload; + + // Verify consistency with the beacon block. + // Use a copy of the header with state_root filled in, matching the spec's approach. + let mut header = state.latest_block_header().clone(); + if header.state_root == Hash256::default() { + // The caller must provide the post-block state root so we can compute + // the block header root without mutating state. + header.state_root = block_state_root; + } + let latest_block_header_root = header.tree_hash_root(); + envelope_verify!( + envelope.beacon_block_root == latest_block_header_root, + EnvelopeProcessingError::LatestBlockHeaderMismatch { + envelope_root: envelope.beacon_block_root, + block_header_root: latest_block_header_root, + } + ); + envelope_verify!( + envelope.parent_beacon_block_root == state.latest_block_header().parent_root, + EnvelopeProcessingError::ParentBeaconBlockRootMismatch { + envelope: envelope.parent_beacon_block_root, + state: state.latest_block_header().parent_root, + } + ); + envelope_verify!( + envelope.slot() == state.slot(), + EnvelopeProcessingError::SlotMismatch { + envelope_slot: envelope.slot(), + parent_state_slot: state.slot(), + } + ); + + // Verify consistency with the committed bid + let committed_bid = state.latest_execution_payload_bid()?; + envelope_verify!( + envelope.builder_index == committed_bid.builder_index, + EnvelopeProcessingError::BuilderIndexMismatch { + committed_bid: committed_bid.builder_index, + envelope: envelope.builder_index, + } + ); + envelope_verify!( + committed_bid.prev_randao == payload.prev_randao, + EnvelopeProcessingError::PrevRandaoMismatch { + committed_bid: committed_bid.prev_randao, + envelope: payload.prev_randao, + } + ); + + // Verify consistency with expected withdrawals + // NOTE: we don't bother hashing here except in case of error, because we can just compare for + // equality directly. This equality check could be more straight-forward if the types were + // changed to match (currently we are comparing VariableList to List). This could happen + // coincidentally when we adopt ProgressiveList. + envelope_verify!( + payload.withdrawals.len() == state.payload_expected_withdrawals()?.len() + && payload + .withdrawals + .iter() + .eq(state.payload_expected_withdrawals()?.iter()), + EnvelopeProcessingError::WithdrawalsRootMismatch { + state: state.payload_expected_withdrawals()?.tree_hash_root(), + payload: payload.withdrawals.tree_hash_root(), + } + ); + + // Verify the gas limit + envelope_verify!( + committed_bid.gas_limit == payload.gas_limit, + EnvelopeProcessingError::GasLimitMismatch { + committed_bid: committed_bid.gas_limit, + envelope: payload.gas_limit, + } + ); + + // Verify the block hash + envelope_verify!( + committed_bid.block_hash == payload.block_hash, + EnvelopeProcessingError::BlockHashMismatch { + committed_bid: committed_bid.block_hash, + envelope: payload.block_hash, + } + ); + + // Verify consistency of the parent hash with respect to the previous execution payload + envelope_verify!( + payload.parent_hash == *state.latest_block_hash()?, + EnvelopeProcessingError::ParentHashMismatch { + state: *state.latest_block_hash()?, + envelope: payload.parent_hash, + } + ); + + // Verify timestamp + let state_timestamp = compute_timestamp_at_slot(state, state.slot(), spec)?; + envelope_verify!( + payload.timestamp == state_timestamp, + EnvelopeProcessingError::TimestampMismatch { + state: state_timestamp, + envelope: payload.timestamp, + } + ); + + // Verify execution requests root matches committed bid + let execution_requests_root = envelope.execution_requests.tree_hash_root(); + envelope_verify!( + execution_requests_root == committed_bid.execution_requests_root, + EnvelopeProcessingError::ExecutionRequestsRootMismatch { + committed_bid: committed_bid.execution_requests_root, + envelope: execution_requests_root, + } + ); + + // TODO(gloas): newPayload happens here in the spec, ensure we wire that up correctly + + Ok(()) +} diff --git a/consensus/state_processing/src/epoch_cache.rs b/consensus/state_processing/src/epoch_cache.rs index ee03596d09..92863ccdb5 100644 --- a/consensus/state_processing/src/epoch_cache.rs +++ b/consensus/state_processing/src/epoch_cache.rs @@ -5,7 +5,7 @@ use crate::metrics; use fixed_bytes::FixedBytesExtended; use safe_arith::SafeArith; use tracing::instrument; -use types::epoch_cache::{EpochCache, EpochCacheError, EpochCacheKey}; +use types::state::{EpochCache, EpochCacheError, EpochCacheKey}; use types::{ActivationQueue, BeaconState, ChainSpec, EthSpec, ForkName, Hash256}; /// Precursor to an `EpochCache`. @@ -74,6 +74,8 @@ impl PreEpochCache { } } + /// Note: the spec-mandated floor (max with EFFECTIVE_BALANCE_INCREMENT) is applied in + /// `into_epoch_cache` and `set_total_active_balance`. This returns the raw sum. pub fn get_total_active_balance(&self) -> u64 { self.total_active_balance } @@ -84,7 +86,12 @@ impl PreEpochCache { spec: &ChainSpec, ) -> Result<EpochCache, EpochCacheError> { let epoch = self.epoch_key.epoch; - let total_active_balance = self.total_active_balance; + // Apply the spec-mandated floor from `get_total_balance`: + // max(EFFECTIVE_BALANCE_INCREMENT, sum(...)) + // This prevents division by zero in base reward calculation when all + // validators have zero effective balance. + let total_active_balance = + std::cmp::max(self.total_active_balance, spec.effective_balance_increment); let sqrt_total_active_balance = SqrtTotalActiveBalance::new(total_active_balance); let base_reward_per_increment = BaseRewardPerIncrement::new(total_active_balance, spec)?; @@ -176,3 +183,40 @@ pub fn initialize_epoch_cache<E: EthSpec>( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use types::Epoch; + + /// Regression test for division-by-zero when all validators have zero effective balance. + /// + /// When `process_effective_balance_updates` drops all effective balances to 0, the + /// `PreEpochCache` accumulates `total_active_balance = 0`. Without the spec-mandated floor + /// of `max(EFFECTIVE_BALANCE_INCREMENT, sum)`, `BaseRewardPerIncrement::new()` would divide + /// by `integer_sqrt(0) = 0`. + #[test] + fn into_epoch_cache_zero_total_active_balance() { + let spec = ChainSpec::minimal(); + + let cache = PreEpochCache { + epoch_key: EpochCacheKey { + epoch: Epoch::new(1), + decision_block_root: Hash256::zero(), + }, + effective_balances: vec![0, 0, 0, 0], + total_active_balance: 0, + }; + + // Verify the raw total is zero. + assert_eq!(cache.get_total_active_balance(), 0); + + // This should succeed, not panic with division by zero. + let epoch_cache = cache + .into_epoch_cache(ActivationQueue::default(), &spec) + .expect("into_epoch_cache should not fail with zero total_active_balance"); + + // Base reward for validator index 0 should be 0. + assert_eq!(epoch_cache.get_base_reward(0).unwrap(), 0); + } +} diff --git a/consensus/state_processing/src/genesis.rs b/consensus/state_processing/src/genesis.rs index 4eee60549c..0900c97aac 100644 --- a/consensus/state_processing/src/genesis.rs +++ b/consensus/state_processing/src/genesis.rs @@ -183,9 +183,19 @@ pub fn initialize_beacon_state_from_eth1<E: EthSpec>( // Remove intermediate Fulu fork from `state.fork`. state.fork_mut().previous_version = spec.gloas_fork_version; - // Override latest execution payload header. - // Here's where we *would* clone the header but there is no header here so.. - // TODO(EIP7732): check this + // The genesis block's bid must have block_hash = 0x00 per spec (empty payload). + // Retain the EL genesis hash in latest_block_hash and parent_block_hash so the + // first post-genesis proposer can build on the correct EL head. + let el_genesis_hash = state.latest_execution_payload_bid()?.block_hash; + let bid = state.latest_execution_payload_bid_mut()?; + bid.parent_block_hash = el_genesis_hash; + bid.block_hash = ExecutionBlockHash::default(); + + // Update the `latest_block_header.body_root` so that it matches the body of the + // Gloas genesis block, which embeds `state.latest_execution_payload_bid` in its + // `signed_execution_payload_bid` field (see `genesis_block`). + let genesis_body_root = genesis_block(&state, spec)?.body_root(); + state.latest_block_header_mut().body_root = genesis_body_root; } // Now that we have our validators, initialize the caches (including the committees) @@ -197,6 +207,27 @@ pub fn initialize_beacon_state_from_eth1<E: EthSpec>( Ok(state) } +/// Create an unsigned genesis `BeaconBlock`. +/// +/// Per spec, the genesis block body is empty (all default fields) except for Gloas, +/// where `body.signed_execution_payload_bid.message` is initialised from +/// `state.latest_execution_payload_bid` so that the first post-genesis proposer can +/// build on the correct execution layer head. +/// +/// `state.latest_block_header.body_root` is set from this same block's body, so the +/// two must stay in sync. +pub fn genesis_block<E: EthSpec>( + state: &BeaconState<E>, + spec: &ChainSpec, +) -> Result<BeaconBlock<E>, BeaconStateError> { + let mut block = BeaconBlock::empty(spec); + if let BeaconBlock::Gloas(ref mut gloas_block) = block { + let bid = state.latest_execution_payload_bid()?.clone(); + gloas_block.body.signed_execution_payload_bid.message = bid; + } + Ok(block) +} + /// Determine whether a candidate genesis state is suitable for starting the chain. pub fn is_valid_genesis_state<E: EthSpec>(state: &BeaconState<E>, spec: &ChainSpec) -> bool { state @@ -211,7 +242,7 @@ pub fn is_valid_genesis_state<E: EthSpec>(state: &BeaconState<E>, spec: &ChainSp pub fn process_activations<E: EthSpec>( state: &mut BeaconState<E>, spec: &ChainSpec, -) -> Result<(), Error> { +) -> Result<(), BeaconStateError> { let (validators, balances, _) = state.validators_and_balances_and_progressive_balances_mut(); let mut validators_iter = validators.iter_cow(); while let Some((index, validator)) = validators_iter.next_cow() { @@ -219,7 +250,7 @@ pub fn process_activations<E: EthSpec>( let balance = balances .get(index) .copied() - .ok_or(Error::BalancesOutOfBounds(index))?; + .ok_or(BeaconStateError::BalancesOutOfBounds(index))?; validator.effective_balance = std::cmp::min( balance.safe_sub(balance.safe_rem(spec.effective_balance_increment)?)?, spec.max_effective_balance, diff --git a/consensus/state_processing/src/lib.rs b/consensus/state_processing/src/lib.rs index 9b2696c6d5..e37c526579 100644 --- a/consensus/state_processing/src/lib.rs +++ b/consensus/state_processing/src/lib.rs @@ -20,6 +20,7 @@ pub mod all_caches; pub mod block_replayer; pub mod common; pub mod consensus_context; +pub mod envelope_processing; pub mod epoch_cache; pub mod genesis; pub mod per_block_processing; diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index c24ddce829..89374ca721 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -1,12 +1,17 @@ use crate::consensus_context::ConsensusContext; -use errors::{BlockOperationError, BlockProcessingError, HeaderInvalid}; +use errors::{ + BlockOperationError, BlockProcessingError, ExecutionPayloadBidInvalid, HeaderInvalid, +}; use rayon::prelude::*; -use safe_arith::{ArithError, SafeArith, SafeArithIter}; -use signature_sets::{block_proposal_signature_set, get_pubkey_from_state, randao_signature_set}; +use safe_arith::{ArithError, SafeArith}; +use signature_sets::{ + block_proposal_signature_set, execution_payload_bid_signature_set, + get_builder_pubkey_from_state, get_pubkey_from_state, randao_signature_set, +}; use std::borrow::Cow; use tree_hash::TreeHash; use typenum::Unsigned; -use types::*; +use types::{consts::gloas::BUILDER_INDEX_SELF_BUILD, *}; pub use self::verify_attester_slashing::{ get_slashable_indices, get_slashable_indices_modular, verify_attester_slashing, @@ -15,6 +20,7 @@ pub use self::verify_proposer_slashing::verify_proposer_slashing; pub use altair::sync_committee::process_sync_aggregate; pub use block_signature_verifier::{BlockSignatureVerifier, ParallelSignatureSets}; pub use is_valid_indexed_attestation::is_valid_indexed_attestation; +pub use is_valid_indexed_payload_attestation::is_valid_indexed_payload_attestation; pub use process_operations::process_operations; pub use verify_attestation::{ verify_attestation_for_block_inclusion, verify_attestation_for_state, @@ -24,12 +30,15 @@ pub use verify_deposit::{ get_existing_validator_index, is_valid_deposit_signature, verify_deposit_merkle_proof, }; pub use verify_exit::verify_exit; +pub use withdrawals::get_expected_withdrawals; pub mod altair; pub mod block_signature_verifier; +pub mod builder; pub mod deneb; pub mod errors; mod is_valid_indexed_attestation; +mod is_valid_indexed_payload_attestation; pub mod process_operations; pub mod signature_sets; pub mod tests; @@ -38,19 +47,20 @@ mod verify_attester_slashing; mod verify_bls_to_execution_change; mod verify_deposit; mod verify_exit; +mod verify_payload_attestation; mod verify_proposer_slashing; +pub mod withdrawals; -use crate::common::decrease_balance; use crate::common::update_progressive_balances_cache::{ initialize_progressive_balances_cache, update_progressive_balances_metrics, }; use crate::epoch_cache::initialize_epoch_cache; -#[cfg(feature = "arbitrary-fuzz")] +#[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; use tracing::instrument; /// The strategy to be used when validating the block's signatures. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(PartialEq, Clone, Copy, Debug)] pub enum BlockSignatureStrategy { /// Do not validate any signature. Use with caution. @@ -64,7 +74,7 @@ pub enum BlockSignatureStrategy { } /// The strategy to be used when validating the block's signatures. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(PartialEq, Clone, Copy)] pub enum VerifySignatures { /// Validate all signatures encountered. @@ -80,7 +90,7 @@ impl VerifySignatures { } /// Control verification of the latest block header. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(PartialEq, Clone, Copy)] pub enum VerifyBlockRoot { True, @@ -110,7 +120,7 @@ pub fn per_block_processing<E: EthSpec, Payload: AbstractExecPayload<E>>( let block = signed_block.message(); // Verify that the `SignedBeaconBlock` instantiation matches the fork at `signed_block.slot()`. - signed_block + let fork_name = signed_block .fork_name(spec) .map_err(BlockProcessingError::InconsistentBlockFork)?; @@ -119,6 +129,11 @@ pub fn per_block_processing<E: EthSpec, Payload: AbstractExecPayload<E>>( .fork_name(spec) .map_err(BlockProcessingError::InconsistentStateFork)?; + // Process deferred execution requests from the parent's envelope. + if fork_name.gloas_enabled() { + process_parent_execution_payload(state, block, spec)?; + } + // Build epoch cache if it hasn't already been built, or if it is no longer valid initialize_epoch_cache(state, spec)?; initialize_progressive_balances_cache(state, spec)?; @@ -172,14 +187,21 @@ pub fn per_block_processing<E: EthSpec, Payload: AbstractExecPayload<E>>( // previous block. if is_execution_enabled(state, block.body()) { let body = block.body(); - // TODO(EIP-7732): build out process_withdrawals variant for gloas - process_withdrawals::<E, Payload>(state, body.execution_payload()?, spec)?; - process_execution_payload::<E, Payload>(state, body, spec)?; + if state.fork_name_unchecked().gloas_enabled() { + withdrawals::gloas::process_withdrawals::<E>(state, spec)?; + process_execution_payload_bid(state, block, verify_signatures, spec)?; + } else { + if state.fork_name_unchecked().capella_enabled() { + withdrawals::capella_electra::process_withdrawals::<E, Payload>( + state, + body.execution_payload()?, + spec, + )?; + } + process_execution_payload::<E, Payload>(state, body, spec)?; + } } - // TODO(EIP-7732): build out process_execution_bid - // process_execution_bid(state, block, verify_signatures, spec)?; - process_randao(state, block, verify_randao, ctxt, spec)?; process_eth1_data(state, block.body().eth1_data())?; process_operations(state, block.body(), verify_signatures, ctxt, spec)?; @@ -322,7 +344,7 @@ pub fn process_randao<E: EthSpec, Payload: AbstractExecPayload<E>>( pub fn process_eth1_data<E: EthSpec>( state: &mut BeaconState<E>, eth1_data: &Eth1Data, -) -> Result<(), Error> { +) -> Result<(), BeaconStateError> { if let Some(new_eth1_data) = get_new_eth1_data(state, eth1_data)? { *state.eth1_data_mut() = new_eth1_data; } @@ -516,193 +538,275 @@ pub fn compute_timestamp_at_slot<E: EthSpec>( ) -> Result<u64, ArithError> { let slots_since_genesis = block_slot.as_u64().safe_sub(spec.genesis_slot.as_u64())?; slots_since_genesis - .safe_mul(spec.seconds_per_slot) + .safe_mul(spec.get_slot_duration().as_secs()) .and_then(|since_genesis| state.genesis_time().safe_add(since_genesis)) } -/// Compute the next batch of withdrawals which should be included in a block. +/// Process the parent block's deferred execution payload effects. /// -/// https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-get_expected_withdrawals -pub fn get_expected_withdrawals<E: EthSpec>( - state: &BeaconState<E>, - spec: &ChainSpec, -) -> Result<(Withdrawals<E>, Option<usize>), BlockProcessingError> { - let epoch = state.current_epoch(); - let mut withdrawal_index = state.next_withdrawal_index()?; - let mut validator_index = state.next_withdrawal_validator_index()?; - let mut withdrawals = Vec::<Withdrawal>::with_capacity(E::max_withdrawals_per_payload()); - let fork_name = state.fork_name_unchecked(); - - // [New in Electra:EIP7251] - // Consume pending partial withdrawals - let processed_partial_withdrawals_count = - if let Ok(pending_partial_withdrawals) = state.pending_partial_withdrawals() { - let mut processed_partial_withdrawals_count = 0; - for withdrawal in pending_partial_withdrawals { - if withdrawal.withdrawable_epoch > epoch - || withdrawals.len() == spec.max_pending_partials_per_withdrawals_sweep as usize - { - break; - } - - let validator = state.get_validator(withdrawal.validator_index as usize)?; - - let has_sufficient_effective_balance = - validator.effective_balance >= spec.min_activation_balance; - let total_withdrawn = withdrawals - .iter() - .filter_map(|w| { - (w.validator_index == withdrawal.validator_index).then_some(w.amount) - }) - .safe_sum()?; - let balance = state - .get_balance(withdrawal.validator_index as usize)? - .safe_sub(total_withdrawn)?; - let has_excess_balance = balance > spec.min_activation_balance; - - if validator.exit_epoch == spec.far_future_epoch - && has_sufficient_effective_balance - && has_excess_balance - { - let withdrawable_balance = std::cmp::min( - balance.safe_sub(spec.min_activation_balance)?, - withdrawal.amount, - ); - withdrawals.push(Withdrawal { - index: withdrawal_index, - validator_index: withdrawal.validator_index, - address: validator - .get_execution_withdrawal_address(spec) - .ok_or(BeaconStateError::NonExecutionAddressWithdrawalCredential)?, - amount: withdrawable_balance, - }); - withdrawal_index.safe_add_assign(1)?; - } - processed_partial_withdrawals_count.safe_add_assign(1)?; - } - Some(processed_partial_withdrawals_count) - } else { - None - }; - - let bound = std::cmp::min( - state.validators().len() as u64, - spec.max_validators_per_withdrawals_sweep, - ); - for _ in 0..bound { - let validator = state.get_validator(validator_index as usize)?; - let partially_withdrawn_balance = withdrawals - .iter() - .filter_map(|withdrawal| { - (withdrawal.validator_index == validator_index).then_some(withdrawal.amount) - }) - .safe_sum()?; - let balance = state - .balances() - .get(validator_index as usize) - .ok_or(BeaconStateError::BalancesOutOfBounds( - validator_index as usize, - ))? - .safe_sub(partially_withdrawn_balance)?; - if validator.is_fully_withdrawable_validator(balance, epoch, spec, fork_name) { - withdrawals.push(Withdrawal { - index: withdrawal_index, - validator_index, - address: validator - .get_execution_withdrawal_address(spec) - .ok_or(BlockProcessingError::WithdrawalCredentialsInvalid)?, - amount: balance, - }); - withdrawal_index.safe_add_assign(1)?; - } else if validator.is_partially_withdrawable_validator(balance, spec, fork_name) { - withdrawals.push(Withdrawal { - index: withdrawal_index, - validator_index, - address: validator - .get_execution_withdrawal_address(spec) - .ok_or(BlockProcessingError::WithdrawalCredentialsInvalid)?, - amount: balance.safe_sub(validator.get_max_effective_balance(spec, fork_name))?, - }); - withdrawal_index.safe_add_assign(1)?; - } - if withdrawals.len() == E::max_withdrawals_per_payload() { - break; - } - validator_index = validator_index - .safe_add(1)? - .safe_rem(state.validators().len() as u64)?; - } - - Ok(( - withdrawals - .try_into() - .map_err(BlockProcessingError::SszTypesError)?, - processed_partial_withdrawals_count, - )) -} - -/// Apply withdrawals to the state. -/// TODO(EIP-7732): abstract this out and create gloas variant -pub fn process_withdrawals<E: EthSpec, Payload: AbstractExecPayload<E>>( +/// This implements the spec's `process_parent_execution_payload` function, which validates +/// the parent execution requests and delegates to `apply_parent_execution_payload` if the +/// parent block was full. This is called at the beginning of block processing, before +/// `process_block_header`. +/// +/// `process_parent_execution_payload` must be called before `process_execution_payload_bid` +/// (which overwrites `state.latest_execution_payload_bid`). +pub fn process_parent_execution_payload<E: EthSpec, Payload: AbstractExecPayload<E>>( state: &mut BeaconState<E>, - payload: Payload::Ref<'_>, + block: BeaconBlockRef<'_, E, Payload>, spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { - if state.fork_name_unchecked().capella_enabled() { - let (expected_withdrawals, processed_partial_withdrawals_count) = - get_expected_withdrawals(state, spec)?; - let expected_root = expected_withdrawals.tree_hash_root(); - let withdrawals_root = payload.withdrawals_root()?; + let bid_parent_block_hash = block + .body() + .signed_execution_payload_bid()? + .message + .parent_block_hash; + let parent_bid = state.latest_execution_payload_bid()?; + let requests = block.body().parent_execution_requests()?; - if expected_root != withdrawals_root { - return Err(BlockProcessingError::WithdrawalsRootMismatch { - expected: expected_root, - found: withdrawals_root, - }); - } - - for withdrawal in expected_withdrawals.iter() { - decrease_balance( - state, - withdrawal.validator_index as usize, - withdrawal.amount, - )?; - } - - // Update pending partial withdrawals [New in Electra:EIP7251] - if let Some(processed_partial_withdrawals_count) = processed_partial_withdrawals_count { - state - .pending_partial_withdrawals_mut()? - .pop_front(processed_partial_withdrawals_count)?; - } - - // Update the next withdrawal index if this block contained withdrawals - if let Some(latest_withdrawal) = expected_withdrawals.last() { - *state.next_withdrawal_index_mut()? = latest_withdrawal.index.safe_add(1)?; - - // Update the next validator index to start the next withdrawal sweep - if expected_withdrawals.len() == E::max_withdrawals_per_payload() { - // Next sweep starts after the latest withdrawal's validator index - let next_validator_index = latest_withdrawal - .validator_index - .safe_add(1)? - .safe_rem(state.validators().len() as u64)?; - *state.next_withdrawal_validator_index_mut()? = next_validator_index; - } - } - - // Advance sweep by the max length of the sweep if there was not a full set of withdrawals - if expected_withdrawals.len() != E::max_withdrawals_per_payload() { - let next_validator_index = state - .next_withdrawal_validator_index()? - .safe_add(spec.max_validators_per_withdrawals_sweep)? - .safe_rem(state.validators().len() as u64)?; - *state.next_withdrawal_validator_index_mut()? = next_validator_index; - } - - Ok(()) - } else { - // these shouldn't even be encountered but they're here for completeness - Ok(()) + if bid_parent_block_hash != parent_bid.block_hash { + // Parent was EMPTY -- no execution requests expected + block_verify!( + *requests == ExecutionRequests::default(), + BlockProcessingError::NonEmptyParentExecutionRequests + ); + return Ok(()); } + + // Parent was FULL -- verify the bid commitment and apply the payload + let requests_root = requests.tree_hash_root(); + block_verify!( + requests_root == parent_bid.execution_requests_root, + BlockProcessingError::ExecutionRequestsRootMismatch { + expected: parent_bid.execution_requests_root, + found: requests_root, + } + ); + + apply_parent_execution_payload(state, requests, spec) +} + +/// Apply the parent execution payload's deferred effects to the state. +/// +/// This implements the spec's `apply_parent_execution_payload` function: +/// 1. Processes deposits, withdrawals, and consolidations from execution requests +/// 2. Queues the builder pending payment from the parent's committed bid +/// 3. Updates `execution_payload_availability` and `latest_block_hash` +pub fn apply_parent_execution_payload<E: EthSpec>( + state: &mut BeaconState<E>, + requests: &ExecutionRequests<E>, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + let parent_bid = state.latest_execution_payload_bid()?.clone(); + let parent_slot = parent_bid.slot; + let parent_epoch = parent_slot.epoch(E::slots_per_epoch()); + + // Process execution requests from the parent's payload + process_operations::process_deposit_requests_post_gloas(state, &requests.deposits, spec)?; + process_operations::process_withdrawal_requests(state, &requests.withdrawals, spec)?; + process_operations::process_consolidation_requests(state, &requests.consolidations, spec)?; + + // Queue the builder payment + if parent_epoch == state.current_epoch() { + let payment_index = E::slots_per_epoch() + .safe_add(parent_slot.as_u64().safe_rem(E::slots_per_epoch())?)? + as usize; + settle_builder_payment(state, payment_index)?; + } else if parent_epoch == state.previous_epoch() { + let payment_index = parent_slot.as_u64().safe_rem(E::slots_per_epoch())? as usize; + settle_builder_payment(state, payment_index)?; + } else if parent_bid.value > 0 { + // Parent is older than previous epoch -- payment entry has already been + // settled or evicted by process_builder_pending_payments at epoch boundaries. + // Append the withdrawal directly from the bid. + state + .builder_pending_withdrawals_mut()? + .push(BuilderPendingWithdrawal { + fee_recipient: parent_bid.fee_recipient, + amount: parent_bid.value, + builder_index: parent_bid.builder_index, + }) + .map_err(|e| BlockProcessingError::BeaconStateError(e.into()))?; + } + + // Update execution payload availability for the parent slot + let availability_index = parent_slot + .as_usize() + .safe_rem(E::slots_per_historical_root())?; + state + .execution_payload_availability_mut()? + .set(availability_index, true) + .map_err(BlockProcessingError::BitfieldError)?; + + // Update latest_block_hash to the parent bid's block_hash + *state.latest_block_hash_mut()? = parent_bid.block_hash; + + Ok(()) +} + +/// Spec: `settle_builder_payment`. +/// +/// Moves a pending payment from `builder_pending_payments[payment_index]` into +/// `builder_pending_withdrawals`, then clears the slot. +pub fn settle_builder_payment<E: EthSpec>( + state: &mut BeaconState<E>, + payment_index: usize, +) -> Result<(), BlockProcessingError> { + let payment_mut = state + .builder_pending_payments_mut()? + .get_mut(payment_index) + .ok_or(BlockProcessingError::BuilderPaymentIndexOutOfBounds( + payment_index, + ))?; + + let withdrawal = payment_mut.withdrawal.clone(); + *payment_mut = BuilderPendingPayment::default(); + + if withdrawal.amount > 0 { + state + .builder_pending_withdrawals_mut()? + .push(withdrawal) + .map_err(|e| BlockProcessingError::BeaconStateError(e.into()))?; + } + + Ok(()) +} + +pub fn process_execution_payload_bid<E: EthSpec, Payload: AbstractExecPayload<E>>( + state: &mut BeaconState<E>, + block: BeaconBlockRef<'_, E, Payload>, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + // Verify the bid signature + let signed_bid = block.body().signed_execution_payload_bid()?; + + let bid = &signed_bid.message; + let amount = bid.value; + let builder_index = bid.builder_index; + + // For self-builds, amount must be zero regardless of withdrawal credential prefix + if builder_index == BUILDER_INDEX_SELF_BUILD { + block_verify!( + amount == 0, + ExecutionPayloadBidInvalid::SelfBuildNonZeroAmount.into() + ); + block_verify!( + signed_bid.signature.is_infinity(), + ExecutionPayloadBidInvalid::BadSignature.into() + ); + } else { + let builder = state.get_builder(builder_index)?; + + // Verify that the builder is active + block_verify!( + state.is_active_builder(builder_index, spec)?, + ExecutionPayloadBidInvalid::BuilderNotActive(builder_index).into() + ); + + // Verify that the builder has funds to cover the bid + block_verify!( + state.can_builder_cover_bid(builder_index, amount, spec)?, + ExecutionPayloadBidInvalid::InsufficientBalance { + builder_index, + builder_balance: builder.balance, + bid_value: amount, + } + .into() + ); + + if verify_signatures.is_true() { + block_verify!( + // We know this is NOT a self-build, so there MUST be a signature set (func does not + // return None). + execution_payload_bid_signature_set( + state, + |i| get_builder_pubkey_from_state(state, i), + signed_bid, + spec + )? + .ok_or(ExecutionPayloadBidInvalid::BadSignature)? + .verify(), + ExecutionPayloadBidInvalid::BadSignature.into() + ); + } + } + + // Verify commitments are under limit + let max_blobs_per_block = spec.max_blobs_per_block(state.current_epoch()) as usize; + block_verify!( + bid.blob_kzg_commitments.len() <= max_blobs_per_block, + ExecutionPayloadBidInvalid::ExcessBlobCommitments { + max: max_blobs_per_block, + bid: bid.blob_kzg_commitments.len(), + } + .into() + ); + + // Verify that the bid is for the current slot + block_verify!( + bid.slot == block.slot(), + ExecutionPayloadBidInvalid::SlotMismatch { + bid_slot: bid.slot, + block_slot: block.slot(), + } + .into() + ); + + // Verify that the bid is for the right parent block + let latest_block_hash = state.latest_block_hash()?; + block_verify!( + bid.parent_block_hash == *latest_block_hash, + ExecutionPayloadBidInvalid::ParentBlockHashMismatch { + state_block_hash: *latest_block_hash, + bid_parent_hash: bid.parent_block_hash, + } + .into() + ); + + block_verify!( + bid.parent_block_root == block.parent_root(), + ExecutionPayloadBidInvalid::ParentBlockRootMismatch { + block_parent_root: block.parent_root(), + bid_parent_root: bid.parent_block_root, + } + .into() + ); + + let expected_randao = *state.get_randao_mix(state.current_epoch())?; + block_verify!( + bid.prev_randao == expected_randao, + ExecutionPayloadBidInvalid::PrevRandaoMismatch { + expected: expected_randao, + bid: bid.prev_randao, + } + .into() + ); + + // Record the pending payment if there is some payment + if amount > 0 { + let pending_payment = BuilderPendingPayment { + weight: 0, + withdrawal: BuilderPendingWithdrawal { + fee_recipient: bid.fee_recipient, + amount, + builder_index, + }, + }; + + let payment_index = E::SlotsPerEpoch::to_usize() + .safe_add(bid.slot.as_usize().safe_rem(E::SlotsPerEpoch::to_usize())?)?; + + *state + .builder_pending_payments_mut()? + .get_mut(payment_index) + .ok_or(BlockProcessingError::BeaconStateError( + BeaconStateError::InvalidBuilderPendingPaymentsIndex(payment_index), + ))? = pending_payment; + } + + // Cache the execution bid + *state.latest_execution_payload_bid_mut()? = bid.clone(); + + Ok(()) } diff --git a/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs b/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs index 9aa44137d8..eea9c17a14 100644 --- a/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs +++ b/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs @@ -1,7 +1,9 @@ #![allow(clippy::arithmetic_side_effects)] use super::signature_sets::{Error as SignatureSetError, *}; -use crate::per_block_processing::errors::{AttestationInvalid, BlockOperationError}; +use crate::per_block_processing::errors::{ + AttestationInvalid, BlockOperationError, PayloadAttestationInvalid, +}; use crate::{ConsensusContext, ContextError}; use bls::{PublicKey, PublicKeyBytes, SignatureSet, verify_signature_sets}; use std::borrow::Cow; @@ -18,6 +20,8 @@ pub enum Error { SignatureInvalid, /// An attestation in the block was invalid. The block is invalid. AttestationValidationError(BlockOperationError<AttestationInvalid>), + /// A payload attestation in the block was invalid. The block is invalid. + PayloadAttestationValidationError(BlockOperationError<PayloadAttestationInvalid>), /// There was an error attempting to read from a `BeaconState`. Block /// validity was not determined. BeaconStateError(BeaconStateError), @@ -66,6 +70,12 @@ impl From<BlockOperationError<AttestationInvalid>> for Error { } } +impl From<BlockOperationError<PayloadAttestationInvalid>> for Error { + fn from(e: BlockOperationError<PayloadAttestationInvalid>) -> Error { + Error::PayloadAttestationValidationError(e) + } +} + /// Reads the BLS signatures and keys from a `SignedBeaconBlock`, storing them as a `Vec<SignatureSet>`. /// /// This allows for optimizations related to batch BLS operations (see the @@ -170,6 +180,8 @@ where self.include_exits(block)?; self.include_sync_aggregate(block)?; self.include_bls_to_execution_changes(block)?; + self.include_execution_payload_bid(block)?; + self.include_payload_attestations(block, ctxt)?; Ok(()) } @@ -295,6 +307,39 @@ where }) } + /// Includes all signatures in `self.block.body.payload_attestations` for verification. + pub fn include_payload_attestations<Payload: AbstractExecPayload<E>>( + &mut self, + block: &'a SignedBeaconBlock<E, Payload>, + ctxt: &mut ConsensusContext<E>, + ) -> Result<()> { + let Ok(payload_attestations) = block.message().body().payload_attestations() else { + // Nothing to do pre-Gloas. + return Ok(()); + }; + + self.sets.sets.reserve(payload_attestations.len()); + + payload_attestations + .iter() + .try_for_each(|payload_attestation| { + let indexed_payload_attestation = ctxt.get_indexed_payload_attestation( + self.state, + payload_attestation, + self.spec, + )?; + + self.sets.push(indexed_payload_attestation_signature_set( + self.state, + self.get_pubkey.clone(), + &payload_attestation.signature, + indexed_payload_attestation, + self.spec, + )?); + Ok(()) + }) + } + /// Includes all signatures in `self.block.body.voluntary_exits` for verification. pub fn include_exits<Payload: AbstractExecPayload<E>>( &mut self, @@ -357,6 +402,27 @@ where Ok(()) } + /// Include the signature of the block's execution payload bid. + pub fn include_execution_payload_bid<Payload: AbstractExecPayload<E>>( + &mut self, + block: &'a SignedBeaconBlock<E, Payload>, + ) -> Result<()> { + if let Ok(signed_execution_payload_bid) = + block.message().body().signed_execution_payload_bid() + { + // TODO(gloas): if we implement a global builder pubkey cache we need to inject it here + if let Some(signature_set) = execution_payload_bid_signature_set( + self.state, + |builder_index| get_builder_pubkey_from_state(self.state, builder_index), + signed_execution_payload_bid, + self.spec, + )? { + self.sets.push(signature_set); + } + } + Ok(()) + } + /// Verify all the signatures that have been included in `self`, returning `true` if and only if /// all the signatures are valid. /// diff --git a/consensus/state_processing/src/per_block_processing/builder.rs b/consensus/state_processing/src/per_block_processing/builder.rs new file mode 100644 index 0000000000..cbaac92c64 --- /dev/null +++ b/consensus/state_processing/src/per_block_processing/builder.rs @@ -0,0 +1,13 @@ +use types::{builder::BuilderIndex, consts::gloas::BUILDER_INDEX_FLAG}; + +pub fn is_builder_index(validator_index: u64) -> bool { + validator_index & BUILDER_INDEX_FLAG != 0 +} + +pub fn convert_builder_index_to_validator_index(builder_index: BuilderIndex) -> u64 { + builder_index | BUILDER_INDEX_FLAG +} + +pub fn convert_validator_index_to_builder_index(validator_index: u64) -> BuilderIndex { + validator_index & !BUILDER_INDEX_FLAG +} diff --git a/consensus/state_processing/src/per_block_processing/deneb.rs b/consensus/state_processing/src/per_block_processing/deneb.rs index a57c080c03..911e7e037e 100644 --- a/consensus/state_processing/src/per_block_processing/deneb.rs +++ b/consensus/state_processing/src/per_block_processing/deneb.rs @@ -1,5 +1,5 @@ use ethereum_hashing::hash_fixed; -use types::{KzgCommitment, VERSIONED_HASH_VERSION_KZG, VersionedHash}; +use types::{VersionedHash, kzg_ext::KzgCommitment, kzg_ext::consts::VERSIONED_HASH_VERSION_KZG}; pub fn kzg_commitment_to_versioned_hash(kzg_commitment: &KzgCommitment) -> VersionedHash { let mut hashed_commitment = hash_fixed(&kzg_commitment.0); diff --git a/consensus/state_processing/src/per_block_processing/errors.rs b/consensus/state_processing/src/per_block_processing/errors.rs index ff7c0204e2..93d668c8c9 100644 --- a/consensus/state_processing/src/per_block_processing/errors.rs +++ b/consensus/state_processing/src/per_block_processing/errors.rs @@ -41,6 +41,10 @@ pub enum BlockProcessingError { index: usize, reason: AttestationInvalid, }, + PayloadAttestationInvalid { + index: usize, + reason: PayloadAttestationInvalid, + }, DepositInvalid { index: usize, reason: DepositInvalid, @@ -90,7 +94,27 @@ pub enum BlockProcessingError { found: Hash256, }, WithdrawalCredentialsInvalid, + /// This should be unreachable unless there's a logical flaw in the spec for withdrawals. + WithdrawalsLimitExceeded { + limit: usize, + prior_withdrawals: usize, + }, + /// Unreachable unless there's a logic error in LH. + IncorrectExpectedWithdrawalsVariant, + MissingLastWithdrawal, PendingAttestationInElectra, + ExecutionPayloadBidInvalid { + reason: ExecutionPayloadBidInvalid, + }, + /// Builder payment index out of bounds (Gloas) + BuilderPaymentIndexOutOfBounds(usize), + /// The parent execution requests root doesn't match the committed bid + ExecutionRequestsRootMismatch { + expected: Hash256, + found: Hash256, + }, + /// Parent was not full but non-empty execution requests were provided + NonEmptyParentExecutionRequests, } impl From<BeaconStateError> for BlockProcessingError { @@ -147,6 +171,12 @@ impl From<milhouse::Error> for BlockProcessingError { } } +impl From<ExecutionPayloadBidInvalid> for BlockProcessingError { + fn from(reason: ExecutionPayloadBidInvalid) -> Self { + Self::ExecutionPayloadBidInvalid { reason } + } +} + impl From<BlockOperationError<HeaderInvalid>> for BlockProcessingError { fn from(e: BlockOperationError<HeaderInvalid>) -> BlockProcessingError { match e { @@ -198,6 +228,7 @@ impl_into_block_processing_error_with_index!( AttesterSlashingInvalid, IndexedAttestationInvalid, AttestationInvalid, + PayloadAttestationInvalid, DepositInvalid, ExitInvalid, BlsExecutionChangeInvalid @@ -364,6 +395,8 @@ pub enum AttestationInvalid { BadSignature, /// The indexed attestation created from this attestation was found to be invalid. BadIndexedAttestation(IndexedAttestationInvalid), + /// The overloaded "data.index" field is invalid (post-Gloas). + BadOverloadedDataIndex, } impl From<BlockOperationError<IndexedAttestationInvalid>> @@ -401,6 +434,52 @@ pub enum IndexedAttestationInvalid { SignatureSetError(SignatureSetError), } +#[derive(Debug, PartialEq, Clone)] +pub enum PayloadAttestationInvalid { + /// Block root does not match the parent beacon block root. + BlockRootMismatch { + expected: Hash256, + found: Hash256, + }, + /// The attestation slot is not the previous slot. + SlotMismatch { + expected: Slot, + found: Slot, + }, + BadIndexedPayloadAttestation(IndexedPayloadAttestationInvalid), +} + +impl From<BlockOperationError<IndexedPayloadAttestationInvalid>> + for BlockOperationError<PayloadAttestationInvalid> +{ + fn from(e: BlockOperationError<IndexedPayloadAttestationInvalid>) -> Self { + match e { + BlockOperationError::Invalid(e) => BlockOperationError::invalid( + PayloadAttestationInvalid::BadIndexedPayloadAttestation(e), + ), + BlockOperationError::BeaconStateError(e) => BlockOperationError::BeaconStateError(e), + BlockOperationError::SignatureSetError(e) => BlockOperationError::SignatureSetError(e), + BlockOperationError::SszTypesError(e) => BlockOperationError::SszTypesError(e), + BlockOperationError::BitfieldError(e) => BlockOperationError::BitfieldError(e), + BlockOperationError::ConsensusContext(e) => BlockOperationError::ConsensusContext(e), + BlockOperationError::ArithError(e) => BlockOperationError::ArithError(e), + } + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum IndexedPayloadAttestationInvalid { + /// The number of indices is 0. + IndicesEmpty, + /// The validator indices were not in increasing order. + BadValidatorIndicesOrdering, + /// The indexed attestation aggregate signature was not valid. + BadSignature, + /// There was an error whilst attempting to get a set of signatures. The signatures may have + /// been invalid or an internal error occurred. + SignatureSetError(SignatureSetError), +} + #[derive(Debug, PartialEq, Clone)] pub enum DepositInvalid { /// The signature (proof-of-possession) does not match the given pubkey. @@ -440,6 +519,38 @@ pub enum ExitInvalid { PendingWithdrawalInQueue(u64), } +#[derive(Debug, PartialEq, Clone)] +pub enum ExecutionPayloadBidInvalid { + /// The validator set a non-zero amount for a self-build. + SelfBuildNonZeroAmount, + /// The signature is invalid. + BadSignature, + /// The builder is not active. + BuilderNotActive(u64), + /// The builder has insufficient balance to cover the bid + InsufficientBalance { + builder_index: u64, + builder_balance: u64, + bid_value: u64, + }, + /// Bid slot doesn't match block slot + SlotMismatch { bid_slot: Slot, block_slot: Slot }, + /// The bid's parent block hash doesn't match the state's latest block hash + ParentBlockHashMismatch { + state_block_hash: ExecutionBlockHash, + bid_parent_hash: ExecutionBlockHash, + }, + /// The bid's parent block root doesn't match the block's parent root + ParentBlockRootMismatch { + block_parent_root: Hash256, + bid_parent_root: Hash256, + }, + /// The bid's prev randao doesn't match the state. + PrevRandaoMismatch { expected: Hash256, bid: Hash256 }, + /// The bid contains more than the maximum number of kzg blob commitments. + ExcessBlobCommitments { max: usize, bid: usize }, +} + #[derive(Debug, PartialEq, Clone)] pub enum BlsExecutionChangeInvalid { /// The specified validator is not in the state's validator registry. diff --git a/consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs b/consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs new file mode 100644 index 0000000000..534f553247 --- /dev/null +++ b/consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs @@ -0,0 +1,32 @@ +use super::errors::{BlockOperationError, IndexedPayloadAttestationInvalid as Invalid}; +use super::signature_sets::{get_pubkey_from_state, indexed_payload_attestation_signature_set}; +use crate::VerifySignatures; +use types::*; + +pub fn is_valid_indexed_payload_attestation<E: EthSpec>( + state: &BeaconState<E>, + indexed_payload_attestation: &IndexedPayloadAttestation<E>, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError<Invalid>> { + // Verify indices are non-empty and sorted (duplicates allowed) + let indices = &indexed_payload_attestation.attesting_indices; + verify!(!indices.is_empty(), Invalid::IndicesEmpty); + verify!(indices.is_sorted(), Invalid::BadValidatorIndicesOrdering); + + if verify_signatures.is_true() { + verify!( + indexed_payload_attestation_signature_set( + state, + |i| get_pubkey_from_state(state, i), + &indexed_payload_attestation.signature, + indexed_payload_attestation, + spec + )? + .verify(), + Invalid::BadSignature + ); + } + + Ok(()) +} diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index 8afeeb685b..422e0afe06 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -4,7 +4,13 @@ use crate::common::{ get_attestation_participation_flag_indices, increase_balance, initiate_validator_exit, slash_validator, }; -use crate::per_block_processing::errors::{BlockProcessingError, IntoWithIndex}; +use crate::per_block_processing::builder::{ + convert_validator_index_to_builder_index, is_builder_index, +}; +use crate::per_block_processing::errors::{BlockProcessingError, ExitInvalid, IntoWithIndex}; +use crate::per_block_processing::signature_sets::{exit_signature_set, get_pubkey_from_state}; +use crate::per_block_processing::verify_payload_attestation::verify_payload_attestation; +use bls::{PublicKeyBytes, SignatureBytes}; use ssz_types::FixedVector; use typenum::U33; use types::consts::altair::{PARTICIPATION_FLAG_WEIGHTS, PROPOSER_WEIGHT, WEIGHT_DENOMINATOR}; @@ -38,9 +44,21 @@ pub fn process_operations<E: EthSpec, Payload: AbstractExecPayload<E>>( process_bls_to_execution_changes(state, bls_to_execution_changes, verify_signatures, spec)?; } - if state.fork_name_unchecked().electra_enabled() { + if state.fork_name_unchecked().gloas_enabled() { + process_payload_attestations( + state, + block_body.payload_attestations()?.iter(), + verify_signatures, + ctxt, + spec, + )?; + } else if state.fork_name_unchecked().electra_enabled() { state.update_pubkey_cache()?; - process_deposit_requests(state, &block_body.execution_requests()?.deposits, spec)?; + process_deposit_requests_pre_gloas( + state, + &block_body.execution_requests()?.deposits, + spec, + )?; process_withdrawal_requests(state, &block_body.execution_requests()?.withdrawals, spec)?; process_consolidation_requests( state, @@ -212,6 +230,148 @@ pub mod altair_deneb { } } +pub mod gloas { + use super::*; + use crate::common::update_progressive_balances_cache::update_progressive_balances_on_attestation; + + pub fn process_attestations<'a, E: EthSpec, I>( + state: &mut BeaconState<E>, + attestations: I, + verify_signatures: VerifySignatures, + ctxt: &mut ConsensusContext<E>, + spec: &ChainSpec, + ) -> Result<(), BlockProcessingError> + where + I: Iterator<Item = AttestationRef<'a, E>>, + { + attestations.enumerate().try_for_each(|(i, attestation)| { + process_attestation(state, attestation, i, ctxt, verify_signatures, spec) + }) + } + + pub fn process_attestation<E: EthSpec>( + state: &mut BeaconState<E>, + attestation: AttestationRef<E>, + att_index: usize, + ctxt: &mut ConsensusContext<E>, + verify_signatures: VerifySignatures, + spec: &ChainSpec, + ) -> Result<(), BlockProcessingError> { + let proposer_index = ctxt.get_proposer_index(state, spec)?; + let previous_epoch = ctxt.previous_epoch; + let current_epoch = ctxt.current_epoch; + + let indexed_att = verify_attestation_for_block_inclusion( + state, + attestation, + ctxt, + verify_signatures, + spec, + ) + .map_err(|e| e.into_with_index(att_index))?; + + // Matching roots, participation flag indices + let data = attestation.data(); + let inclusion_delay = state.slot().safe_sub(data.slot)?.as_u64(); + let participation_flag_indices = + get_attestation_participation_flag_indices(state, data, inclusion_delay, spec)?; + + // [New in EIP-7732] + let current_epoch_target = data.target.epoch == state.current_epoch(); + let slot_mod = data + .slot + .as_usize() + .safe_rem(E::slots_per_epoch() as usize)?; + let payment_index = if current_epoch_target { + (E::slots_per_epoch() as usize).safe_add(slot_mod)? + } else { + slot_mod + }; + // Cached here to avoid repeat lookups. The withdrawal amount is immutable throughout + // this whole function. + let payment_withdrawal_amount = state + .builder_pending_payments()? + .get(payment_index) + .ok_or(BlockProcessingError::BuilderPaymentIndexOutOfBounds( + payment_index, + ))? + .withdrawal + .amount; + + // Update epoch participation flags. + let mut proposer_reward_numerator = 0; + for index in indexed_att.attesting_indices_iter() { + let index = *index as usize; + + let validator_effective_balance = state.epoch_cache().get_effective_balance(index)?; + let validator_slashed = state.slashings_cache().is_slashed(index); + + // [New in Gloas:EIP7732] + // For same-slot attestations, check if we're setting any new flags + // If we are, this validator hasn't contributed to this slot's quorum yet + let mut will_set_new_flag = false; + + for (flag_index, &weight) in PARTICIPATION_FLAG_WEIGHTS.iter().enumerate() { + let epoch_participation = state.get_epoch_participation_mut( + data.target.epoch, + previous_epoch, + current_epoch, + )?; + + if participation_flag_indices.contains(&flag_index) { + let validator_participation = epoch_participation + .get_mut(index) + .ok_or(BeaconStateError::ParticipationOutOfBounds(index))?; + + if !validator_participation.has_flag(flag_index)? { + validator_participation.add_flag(flag_index)?; + proposer_reward_numerator + .safe_add_assign(state.get_base_reward(index)?.safe_mul(weight)?)?; + will_set_new_flag = true; + + update_progressive_balances_on_attestation( + state, + data.target.epoch, + flag_index, + validator_effective_balance, + validator_slashed, + )?; + } + } + } + + // [New in Gloas:EIP7732] + // Add weight for same-slot attestations when any new flag is set. + // This ensures each validator contributes exactly once per slot. + if will_set_new_flag + && state.is_attestation_same_slot(data)? + && payment_withdrawal_amount > 0 + { + let builder_payments = state.builder_pending_payments_mut()?; + let payment = builder_payments.get_mut(payment_index).ok_or( + BlockProcessingError::BuilderPaymentIndexOutOfBounds(payment_index), + )?; + payment + .weight + .safe_add_assign(validator_effective_balance)?; + } + } + + let proposer_reward_denominator = WEIGHT_DENOMINATOR + .safe_sub(PROPOSER_WEIGHT)? + .safe_mul(WEIGHT_DENOMINATOR)? + .safe_div(PROPOSER_WEIGHT)?; + let proposer_reward = proposer_reward_numerator.safe_div(proposer_reward_denominator)?; + increase_balance(state, proposer_index as usize, proposer_reward)?; + + // [New in Gloas:EIP7732] + // Update builder payment weight + // No-op, this is done inline above. + + Ok(()) + } +} + /// Validates each `ProposerSlashing` and updates the state, short-circuiting on an invalid object. /// /// Returns `Ok(())` if the validation and state updates completed successfully, otherwise returns @@ -235,6 +395,31 @@ pub fn process_proposer_slashings<E: EthSpec>( verify_proposer_slashing(proposer_slashing, state, verify_signatures, spec) .map_err(|e| e.into_with_index(i))?; + // [New in Gloas:EIP7732] + // Remove the BuilderPendingPayment corresponding to this proposal + // if it is still in the 2-epoch window. + if state.fork_name_unchecked().gloas_enabled() { + let slot = proposer_slashing.signed_header_1.message.slot; + let proposal_epoch = slot.epoch(E::slots_per_epoch()); + let slot_in_epoch = slot.as_usize().safe_rem(E::SlotsPerEpoch::to_usize())?; + + let payment_index = if proposal_epoch == state.current_epoch() { + Some(E::SlotsPerEpoch::to_usize().safe_add(slot_in_epoch)?) + } else if proposal_epoch == state.previous_epoch() { + Some(slot_in_epoch) + } else { + None + }; + + if let Some(index) = payment_index { + let payment = state + .builder_pending_payments_mut()? + .get_mut(index) + .ok_or(BlockProcessingError::BuilderPaymentIndexOutOfBounds(index))?; + *payment = BuilderPendingPayment::default(); + } + } + slash_validator( state, proposer_slashing.signed_header_1.message.proposer_index as usize, @@ -285,7 +470,15 @@ pub fn process_attestations<E: EthSpec, Payload: AbstractExecPayload<E>>( ctxt: &mut ConsensusContext<E>, spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { - if state.fork_name_unchecked().altair_enabled() { + if state.fork_name_unchecked().gloas_enabled() { + gloas::process_attestations( + state, + block_body.attestations(), + verify_signatures, + ctxt, + spec, + )?; + } else if state.fork_name_unchecked().altair_enabled() { altair_deneb::process_attestations( state, block_body.attestations(), @@ -318,7 +511,26 @@ pub fn process_exits<E: EthSpec>( // Verify and apply each exit in series. We iterate in series because higher-index exits may // become invalid due to the application of lower-index ones. for (i, exit) in voluntary_exits.iter().enumerate() { - verify_exit(state, None, exit, verify_signatures, spec) + // Exits must specify an epoch when they become valid; they are not valid before then. + let current_epoch = state.current_epoch(); + if current_epoch < exit.message.epoch { + return Err(BlockOperationError::invalid(ExitInvalid::FutureEpoch { + state: current_epoch, + exit: exit.message.epoch, + }) + .into_with_index(i)); + } + + // [New in Gloas:EIP7732] + if state.fork_name_unchecked().gloas_enabled() + && is_builder_index(exit.message.validator_index) + { + process_builder_voluntary_exit(state, exit, verify_signatures, spec) + .map_err(|e| e.into_with_index(i))?; + continue; + } + + verify_exit(state, Some(current_epoch), exit, verify_signatures, spec) .map_err(|e| e.into_with_index(i))?; initiate_validator_exit(state, exit.message.validator_index as usize, spec)?; @@ -326,6 +538,82 @@ pub fn process_exits<E: EthSpec>( Ok(()) } +/// Process a builder voluntary exit. [New in Gloas:EIP7732] +fn process_builder_voluntary_exit<E: EthSpec>( + state: &mut BeaconState<E>, + signed_exit: &SignedVoluntaryExit, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError<ExitInvalid>> { + let builder_index = + convert_validator_index_to_builder_index(signed_exit.message.validator_index); + + // Verify builder is known + state + .builders()? + .get(builder_index as usize) + .cloned() + .ok_or(BlockOperationError::invalid(ExitInvalid::ValidatorUnknown( + signed_exit.message.validator_index, + )))?; + + // Verify the builder is active + if !state.is_active_builder(builder_index, spec)? { + return Err(BlockOperationError::invalid(ExitInvalid::NotActive( + signed_exit.message.validator_index, + ))); + } + + // Only exit builder if it has no pending withdrawals in the queue + let pending_balance = state.get_pending_balance_to_withdraw_for_builder(builder_index)?; + if pending_balance != 0 { + return Err(BlockOperationError::invalid( + ExitInvalid::PendingWithdrawalInQueue(signed_exit.message.validator_index), + )); + } + + if verify_signatures.is_true() { + verify!( + exit_signature_set( + state, + |i| get_pubkey_from_state(state, i), + signed_exit, + spec + )? + .verify(), + ExitInvalid::BadSignature + ); + } + + // Initiate builder exit + initiate_builder_exit(state, builder_index, spec)?; + + Ok(()) +} + +/// Initiate the exit of a builder. [New in Gloas:EIP7732] +fn initiate_builder_exit<E: EthSpec>( + state: &mut BeaconState<E>, + builder_index: u64, + spec: &ChainSpec, +) -> Result<(), BeaconStateError> { + let current_epoch = state.current_epoch(); + let builder = state + .builders_mut()? + .get_mut(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))?; + + // Return if builder already initiated exit + if builder.withdrawable_epoch != spec.far_future_epoch { + return Ok(()); + } + + // Set builder exit epoch + builder.withdrawable_epoch = current_epoch.safe_add(spec.min_builder_withdrawability_delay)?; + + Ok(()) +} + /// Validates each `bls_to_execution_change` and updates the state /// /// Returns `Ok(())` if the validation and state updates completed successfully. Otherwise returns @@ -586,7 +874,7 @@ pub fn process_withdrawal_requests<E: EthSpec>( Ok(()) } -pub fn process_deposit_requests<E: EthSpec>( +pub fn process_deposit_requests_pre_gloas<E: EthSpec>( state: &mut BeaconState<E>, deposit_requests: &[DepositRequest], spec: &ChainSpec, @@ -613,6 +901,140 @@ pub fn process_deposit_requests<E: EthSpec>( Ok(()) } +pub fn process_deposit_requests_post_gloas<E: EthSpec>( + state: &mut BeaconState<E>, + deposit_requests: &[DepositRequest], + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + for request in deposit_requests { + process_deposit_request_post_gloas(state, request, spec)?; + } + + Ok(()) +} + +/// Check if there is a pending deposit for a new validator with the given pubkey. +// TODO(gloas): cache the deposit signature validation or remove this loop entirely if possible, +// it is `O(n * m)` where `n` is max 8192 and `m` is max 128M. +fn is_pending_validator<E: EthSpec>( + state: &BeaconState<E>, + pubkey: &PublicKeyBytes, + spec: &ChainSpec, +) -> Result<bool, BlockProcessingError> { + for deposit in state.pending_deposits()?.iter() { + if deposit.pubkey == *pubkey { + let deposit_data = DepositData { + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + signature: deposit.signature.clone(), + }; + if is_valid_deposit_signature(&deposit_data, spec).is_ok() { + return Ok(true); + } + } + } + Ok(false) +} + +pub fn process_deposit_request_post_gloas<E: EthSpec>( + state: &mut BeaconState<E>, + deposit_request: &DepositRequest, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + // [New in Gloas:EIP7732] + // Regardless of the withdrawal credentials prefix, if a builder/validator + // already exists with this pubkey, apply the deposit to their balance + // TODO(gloas): this could be more efficient in the builder case, see: + // https://github.com/sigp/lighthouse/issues/8783 + let builder_index = state + .builders()? + .iter() + .enumerate() + .find(|(_, builder)| builder.pubkey == deposit_request.pubkey) + .map(|(i, _)| i as u64); + let is_builder = builder_index.is_some(); + + let validator_index = state.get_validator_index(&deposit_request.pubkey)?; + let is_validator = validator_index.is_some(); + + let has_builder_prefix = + is_builder_withdrawal_credential(deposit_request.withdrawal_credentials, spec); + + if is_builder + || (has_builder_prefix + && !is_validator + && !is_pending_validator(state, &deposit_request.pubkey, spec)?) + { + // Apply builder deposits immediately + apply_deposit_for_builder( + state, + builder_index, + deposit_request.pubkey, + deposit_request.withdrawal_credentials, + deposit_request.amount, + deposit_request.signature.clone(), + state.slot(), + spec, + )?; + return Ok(()); + } + + // Add validator deposits to the queue + let slot = state.slot(); + state.pending_deposits_mut()?.push(PendingDeposit { + pubkey: deposit_request.pubkey, + withdrawal_credentials: deposit_request.withdrawal_credentials, + amount: deposit_request.amount, + signature: deposit_request.signature.clone(), + slot, + })?; + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn apply_deposit_for_builder<E: EthSpec>( + state: &mut BeaconState<E>, + builder_index_opt: Option<BuilderIndex>, + pubkey: PublicKeyBytes, + withdrawal_credentials: Hash256, + amount: u64, + signature: SignatureBytes, + slot: Slot, + spec: &ChainSpec, +) -> Result<(), BeaconStateError> { + match builder_index_opt { + None => { + // Verify the deposit signature (proof of possession) which is not checked by the deposit contract + let deposit_data = DepositData { + pubkey, + withdrawal_credentials, + amount, + signature, + }; + if is_valid_deposit_signature(&deposit_data, spec).is_ok() { + state.add_builder_to_registry( + pubkey, + withdrawal_credentials, + amount, + slot, + spec, + )?; + } + } + Some(builder_index) => { + state + .builders_mut()? + .get_mut(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))? + .balance + .safe_add_assign(amount)?; + } + } + Ok(()) +} + // Make sure to build the pubkey cache before calling this function pub fn process_consolidation_requests<E: EthSpec>( state: &mut BeaconState<E>, @@ -787,3 +1209,45 @@ pub fn process_consolidation_request<E: EthSpec>( Ok(()) } + +pub fn process_payload_attestation<E: EthSpec>( + state: &mut BeaconState<E>, + payload_attestation: &PayloadAttestation<E>, + att_index: usize, + verify_signatures: VerifySignatures, + ctxt: &mut ConsensusContext<E>, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + verify_payload_attestation(state, payload_attestation, ctxt, verify_signatures, spec) + .map_err(|e| e.into_with_index(att_index)) +} + +pub fn process_payload_attestations<'a, E: EthSpec, I>( + state: &mut BeaconState<E>, + payload_attestations: I, + verify_signatures: VerifySignatures, + ctxt: &mut ConsensusContext<E>, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> +where + I: Iterator<Item = &'a PayloadAttestation<E>>, +{ + // Presently the PTC cache requires the committee cache for `state.slot() - 1` which is either + // in the current or previous epoch. + // TODO(gloas): These requirements may change if we introduce a PTC cache. + state.build_committee_cache(RelativeEpoch::Current, spec)?; + state.build_committee_cache(RelativeEpoch::Previous, spec)?; + + payload_attestations + .enumerate() + .try_for_each(|(i, payload_attestation)| { + process_payload_attestation( + state, + payload_attestation, + i, + verify_signatures, + ctxt, + spec, + ) + }) +} diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index 0e936007ee..0686c4d605 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -2,6 +2,7 @@ //! validated individually, or alongside in others in a potentially cheaper bulk operation. //! //! This module exposes one function to extract each type of `SignatureSet` from a `BeaconBlock`. +use super::builder::{convert_validator_index_to_builder_index, is_builder_index}; use bls::{AggregateSignature, PublicKey, PublicKeyBytes, Signature, SignatureSet}; use ssz::DecodeError; use std::borrow::Cow; @@ -9,11 +10,12 @@ use tree_hash::TreeHash; use typenum::Unsigned; use types::{ AbstractExecPayload, AttesterSlashingRef, BeaconBlockRef, BeaconState, BeaconStateError, - ChainSpec, DepositData, Domain, Epoch, EthSpec, Fork, Hash256, InconsistentFork, - IndexedAttestation, IndexedAttestationRef, ProposerSlashing, SignedAggregateAndProof, - SignedBeaconBlock, SignedBeaconBlockHeader, SignedBlsToExecutionChange, - SignedContributionAndProof, SignedRoot, SignedVoluntaryExit, SigningData, Slot, SyncAggregate, - SyncAggregatorSelectionData, + BuilderIndex, ChainSpec, DepositData, Domain, Epoch, EthSpec, Fork, Hash256, InconsistentFork, + IndexedAttestation, IndexedAttestationRef, IndexedPayloadAttestation, ProposerSlashing, + SignedAggregateAndProof, SignedBeaconBlock, SignedBeaconBlockHeader, + SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, + SignedProposerPreferences, SignedRoot, SignedVoluntaryExit, SigningData, Slot, SyncAggregate, + SyncAggregatorSelectionData, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; pub type Result<T> = std::result::Result<T, Error>; @@ -28,6 +30,9 @@ pub enum Error { /// Attempted to find the public key of a validator that does not exist. You cannot distinguish /// between an error and an invalid block in this case. ValidatorUnknown(u64), + /// Attempted to find the public key of a builder that does not exist. You cannot distinguish + /// between an error and an invalid block in this case. + BuilderUnknown(BuilderIndex), /// Attempted to find the public key of a validator that does not exist. You cannot distinguish /// between an error and an invalid block in this case. ValidatorPubkeyUnknown(PublicKeyBytes), @@ -53,7 +58,7 @@ impl From<BeaconStateError> for Error { } } -/// Helper function to get a public key from a `state`. +/// Helper function to get a validator public key from a `state`. pub fn get_pubkey_from_state<E>( state: &BeaconState<E>, validator_index: usize, @@ -71,6 +76,25 @@ where .map(Cow::Owned) } +/// Helper function to get a builder public key from a `state`. +pub fn get_builder_pubkey_from_state<E>( + state: &BeaconState<E>, + builder_index: BuilderIndex, +) -> Option<Cow<'_, PublicKey>> +where + E: EthSpec, +{ + state + .builders() + .ok()? + .get(builder_index as usize) + .and_then(|b| { + let pk: Option<PublicKey> = b.pubkey.decompress().ok(); + pk + }) + .map(Cow::Owned) +} + /// A signature set that is valid if a block was signed by the expected block producer. pub fn block_proposal_signature_set<'a, E, F, Payload: AbstractExecPayload<E>>( state: &'a BeaconState<E>, @@ -332,6 +356,112 @@ where Ok(SignatureSet::multiple_pubkeys(signature, pubkeys, message)) } +pub fn indexed_payload_attestation_signature_set<'a, 'b, E, F>( + state: &'a BeaconState<E>, + get_pubkey: F, + signature: &'a AggregateSignature, + indexed_payload_attestation: &'b IndexedPayloadAttestation<E>, + spec: &'a ChainSpec, +) -> Result<SignatureSet<'a>> +where + E: EthSpec, + F: Fn(usize) -> Option<Cow<'a, PublicKey>>, +{ + let mut pubkeys = Vec::with_capacity(indexed_payload_attestation.attesting_indices.len()); + for &validator_idx in indexed_payload_attestation.attesting_indices.iter() { + pubkeys.push( + get_pubkey(validator_idx as usize).ok_or(Error::ValidatorUnknown(validator_idx))?, + ); + } + + let epoch = indexed_payload_attestation + .data + .slot + .epoch(E::slots_per_epoch()); + let domain = spec.get_domain( + epoch, + Domain::PTCAttester, + &state.fork(), + state.genesis_validators_root(), + ); + + let message = indexed_payload_attestation.data.signing_root(domain); + + Ok(SignatureSet::multiple_pubkeys(signature, pubkeys, message)) +} + +pub fn proposer_preferences_signature_set<'a, E, F>( + state: &'a BeaconState<E>, + get_pubkey: F, + signed_proposer_preferences: &'a SignedProposerPreferences, + spec: &'a ChainSpec, +) -> Result<SignatureSet<'a>> +where + E: EthSpec, + F: Fn(usize) -> Option<Cow<'a, PublicKey>>, +{ + let preferences = &signed_proposer_preferences.message; + let validator_index = preferences.validator_index as usize; + + let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); + let proposal_fork = spec.fork_at_epoch(proposal_epoch); + let domain = spec.get_domain( + proposal_epoch, + Domain::ProposerPreferences, + &proposal_fork, + state.genesis_validators_root(), + ); + + let message = preferences.signing_root(domain); + + Ok(SignatureSet::single_pubkey( + &signed_proposer_preferences.signature, + get_pubkey(validator_index).ok_or(Error::ValidatorUnknown(validator_index as u64))?, + message, + )) +} + +pub fn execution_payload_bid_signature_set<'a, E, F>( + state: &'a BeaconState<E>, + get_builder_pubkey: F, + signed_execution_payload_bid: &'a SignedExecutionPayloadBid<E>, + spec: &'a ChainSpec, +) -> Result<Option<SignatureSet<'a>>> +where + E: EthSpec, + F: Fn(BuilderIndex) -> Option<Cow<'a, PublicKey>>, +{ + let execution_payload_bid = &signed_execution_payload_bid.message; + let builder_index = execution_payload_bid.builder_index; + if builder_index == BUILDER_INDEX_SELF_BUILD { + // No signatures to verify in case of a self-build, but consensus code MUST check that + // the signature is the point at infinity. + // See `process_execution_payload_bid`. + return Ok(None); + } + + let bid_epoch = signed_execution_payload_bid + .message + .slot + .epoch(E::slots_per_epoch()); + let bid_fork = spec.fork_at_epoch(bid_epoch); + let domain = spec.get_domain( + bid_epoch, + Domain::BeaconBuilder, + &bid_fork, + state.genesis_validators_root(), + ); + + let pubkey = get_builder_pubkey(builder_index).ok_or(Error::BuilderUnknown(builder_index))?; + let message = execution_payload_bid.signing_root(domain); + + Ok(Some(SignatureSet::single_pubkey( + &signed_execution_payload_bid.signature, + pubkey, + message, + ))) +} + /// Returns the signature set for the given `attester_slashing` and corresponding `pubkeys`. pub fn attester_slashing_signature_sets<'a, E, F>( state: &'a BeaconState<E>, @@ -374,7 +504,7 @@ pub fn deposit_pubkey_signature_message( } /// Returns a signature set that is valid if the `SignedVoluntaryExit` was signed by the indicated -/// validator. +/// validator (or builder, in the case of a builder exit). pub fn exit_signature_set<'a, E, F>( state: &'a BeaconState<E>, get_pubkey: F, @@ -386,7 +516,18 @@ where F: Fn(usize) -> Option<Cow<'a, PublicKey>>, { let exit = &signed_exit.message; - let proposer_index = exit.validator_index as usize; + let validator_index = exit.validator_index; + + let is_builder_exit = + state.fork_name_unchecked().gloas_enabled() && is_builder_index(validator_index); + + let pubkey = if is_builder_exit { + let builder_index = convert_validator_index_to_builder_index(validator_index); + get_builder_pubkey_from_state(state, builder_index) + .ok_or(Error::ValidatorUnknown(validator_index))? + } else { + get_pubkey(validator_index as usize).ok_or(Error::ValidatorUnknown(validator_index))? + }; let domain = if state.fork_name_unchecked().deneb_enabled() { // EIP-7044 @@ -408,7 +549,7 @@ where Ok(SignatureSet::single_pubkey( &signed_exit.signature, - get_pubkey(proposer_index).ok_or(Error::ValidatorUnknown(proposer_index as u64))?, + pubkey, message, )) } diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 739717b33f..96610c2010 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -8,7 +8,7 @@ use crate::per_block_processing::errors::{ use crate::{BlockReplayError, BlockReplayer, per_block_processing}; use crate::{ BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, VerifySignatures, - per_block_processing::{process_operations, verify_exit::verify_exit}, + per_block_processing::process_operations, }; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use bls::{AggregateSignature, Keypair, PublicKeyBytes, Signature, SignatureBytes}; @@ -39,10 +39,13 @@ async fn get_harness<E: EthSpec>( // Set the state and block to be in the last slot of the `epoch_offset`th epoch. let last_slot_of_epoch = (MainnetEthSpec::genesis_epoch() + epoch_offset).end_slot(E::slots_per_epoch()); + // Use Electra spec to ensure blocks are created at the same fork as the state + let spec = Arc::new(ForkName::Electra.make_genesis_spec(E::default_spec())); let harness = BeaconChainHarness::<EphemeralHarnessType<E>>::builder(E::default()) - .default_spec() + .spec(spec.clone()) .keypairs(KEYPAIRS[0..num_validators].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); let state = harness.get_current_state(); if last_slot_of_epoch > Slot::new(0) { @@ -63,8 +66,8 @@ async fn get_harness<E: EthSpec>( #[tokio::test] async fn valid_block_ok() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -87,8 +90,8 @@ async fn valid_block_ok() { #[tokio::test] async fn invalid_block_header_state_slot() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot() + Slot::new(1); @@ -107,18 +110,18 @@ async fn invalid_block_header_state_slot() { &spec, ); - assert_eq!( + assert!(matches!( result, Err(BlockProcessingError::HeaderInvalid { - reason: HeaderInvalid::StateSlotMismatch + reason: HeaderInvalid::StateSlotMismatch, }) - ); + )); } #[tokio::test] async fn invalid_parent_block_root() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -139,21 +142,18 @@ async fn invalid_parent_block_root() { &spec, ); - assert_eq!( + assert!(matches!( result, Err(BlockProcessingError::HeaderInvalid { - reason: HeaderInvalid::ParentBlockRootMismatch { - state: state.latest_block_header().canonical_root(), - block: Hash256::from([0xAA; 32]) - } + reason: HeaderInvalid::ParentBlockRootMismatch { .. }, }) - ); + )); } #[tokio::test] async fn invalid_block_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -172,19 +172,18 @@ async fn invalid_block_signature() { &spec, ); - // should get a BadSignature error - assert_eq!( + assert!(matches!( result, Err(BlockProcessingError::HeaderInvalid { - reason: HeaderInvalid::ProposalSignatureInvalid + reason: HeaderInvalid::ProposalSignatureInvalid, }) - ); + )); } #[tokio::test] async fn invalid_randao_reveal_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -211,8 +210,8 @@ async fn invalid_randao_reveal_signature() { #[tokio::test] async fn valid_4_deposits() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 4, None, None); @@ -235,8 +234,8 @@ async fn valid_4_deposits() { #[tokio::test] async fn invalid_deposit_deposit_count_too_big() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 1, None, None); @@ -267,8 +266,8 @@ async fn invalid_deposit_deposit_count_too_big() { #[tokio::test] async fn invalid_deposit_count_too_small() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 1, None, None); @@ -299,8 +298,8 @@ async fn invalid_deposit_count_too_small() { #[tokio::test] async fn invalid_deposit_bad_merkle_proof() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 1, None, None); @@ -333,8 +332,8 @@ async fn invalid_deposit_bad_merkle_proof() { #[tokio::test] async fn invalid_deposit_wrong_sig() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = @@ -357,8 +356,8 @@ async fn invalid_deposit_wrong_sig() { #[tokio::test] async fn invalid_deposit_invalid_pub_key() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = @@ -382,8 +381,8 @@ async fn invalid_deposit_invalid_pub_key() { #[tokio::test] async fn invalid_attestation_no_committee_for_index() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -422,8 +421,8 @@ async fn invalid_attestation_no_committee_for_index() { #[tokio::test] async fn invalid_attestation_wrong_justified_checkpoint() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -477,8 +476,8 @@ async fn invalid_attestation_wrong_justified_checkpoint() { #[tokio::test] async fn invalid_attestation_bad_aggregation_bitfield_len() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -488,13 +487,14 @@ async fn invalid_attestation_bad_aggregation_bitfield_len() { .clone() .deconstruct() .0; + // Use Electra method since harness runs at Electra fork *head_block .to_mut() .body_mut() .attestations_mut() .next() .unwrap() - .aggregation_bits_base_mut() + .aggregation_bits_electra_mut() .unwrap() = Bitfield::with_capacity(spec.target_committee_size).unwrap(); let mut ctxt = ConsensusContext::new(state.slot()); @@ -506,19 +506,20 @@ async fn invalid_attestation_bad_aggregation_bitfield_len() { &spec, ); - // Expecting InvalidBitfield because the size of the aggregation_bitfield is bigger than the committee size. + // In Electra, setting wrong aggregation_bits capacity causes EmptyCommittee error + // (validation order changed - committee check happens before bitfield check) assert_eq!( result, Err(BlockProcessingError::BeaconStateError( - BeaconStateError::InvalidBitfield + BeaconStateError::EmptyCommittee )) ); } #[tokio::test] async fn invalid_attestation_bad_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, 97).await; // minimal number of required validators for this test + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -558,8 +559,8 @@ async fn invalid_attestation_bad_signature() { #[tokio::test] async fn invalid_attestation_included_too_early() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -603,56 +604,15 @@ async fn invalid_attestation_included_too_early() { ); } -#[tokio::test] -async fn invalid_attestation_included_too_late() { - let spec = MainnetEthSpec::default_spec(); - // note to maintainer: might need to increase validator count if we get NoCommittee - let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; - - let mut state = harness.get_current_state(); - let mut head_block = harness - .chain - .head_beacon_block() - .as_ref() - .clone() - .deconstruct() - .0; - let new_attesation_slot = head_block.body().attestations().next().unwrap().data().slot - - Slot::new(MainnetEthSpec::slots_per_epoch()); - head_block - .to_mut() - .body_mut() - .attestations_mut() - .next() - .unwrap() - .data_mut() - .slot = new_attesation_slot; - - let mut ctxt = ConsensusContext::new(state.slot()); - let result = process_operations::process_attestations( - &mut state, - head_block.body(), - VerifySignatures::True, - &mut ctxt, - &spec, - ); - assert_eq!( - result, - Err(BlockProcessingError::AttestationInvalid { - index: 0, - reason: AttestationInvalid::IncludedTooLate { - state: state.slot(), - attestation: new_attesation_slot, - } - }) - ); -} +// Note: `invalid_attestation_included_too_late` test removed. +// The `IncludedTooLate` check was removed in Deneb (EIP7045), so this test is no longer +// applicable when running with Electra spec (which the harness uses by default). #[tokio::test] async fn invalid_attestation_target_epoch_slot_mismatch() { - let spec = MainnetEthSpec::default_spec(); // note to maintainer: might need to increase validator count if we get NoCommittee let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -694,8 +654,8 @@ async fn invalid_attestation_target_epoch_slot_mismatch() { #[tokio::test] async fn valid_insert_attester_slashing() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let attester_slashing = harness.make_attester_slashing(vec![1, 2]); @@ -715,8 +675,8 @@ async fn valid_insert_attester_slashing() { #[tokio::test] async fn invalid_attester_slashing_not_slashable() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut attester_slashing = harness.make_attester_slashing(vec![1, 2]); match &mut attester_slashing { @@ -750,8 +710,8 @@ async fn invalid_attester_slashing_not_slashable() { #[tokio::test] async fn invalid_attester_slashing_1_invalid() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut attester_slashing = harness.make_attester_slashing(vec![1, 2]); match &mut attester_slashing { @@ -790,8 +750,8 @@ async fn invalid_attester_slashing_1_invalid() { #[tokio::test] async fn invalid_attester_slashing_2_invalid() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut attester_slashing = harness.make_attester_slashing(vec![1, 2]); match &mut attester_slashing { @@ -830,8 +790,8 @@ async fn invalid_attester_slashing_2_invalid() { #[tokio::test] async fn valid_insert_proposer_slashing() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let proposer_slashing = harness.make_proposer_slashing(1); let mut state = harness.get_current_state(); let mut ctxt = ConsensusContext::new(state.slot()); @@ -848,8 +808,8 @@ async fn valid_insert_proposer_slashing() { #[tokio::test] async fn invalid_proposer_slashing_proposals_identical() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.message = proposer_slashing.signed_header_2.message.clone(); @@ -876,8 +836,8 @@ async fn invalid_proposer_slashing_proposals_identical() { #[tokio::test] async fn invalid_proposer_slashing_proposer_unknown() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.message.proposer_index = 3_141_592; @@ -905,8 +865,8 @@ async fn invalid_proposer_slashing_proposer_unknown() { #[tokio::test] async fn invalid_proposer_slashing_duplicate_slashing() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let proposer_slashing = harness.make_proposer_slashing(1); let mut state = harness.get_current_state(); @@ -939,8 +899,8 @@ async fn invalid_proposer_slashing_duplicate_slashing() { #[tokio::test] async fn invalid_bad_proposal_1_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.signature = Signature::empty(); let mut state = harness.get_current_state(); @@ -965,8 +925,8 @@ async fn invalid_bad_proposal_1_signature() { #[tokio::test] async fn invalid_bad_proposal_2_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_2.signature = Signature::empty(); let mut state = harness.get_current_state(); @@ -991,8 +951,8 @@ async fn invalid_bad_proposal_2_signature() { #[tokio::test] async fn invalid_proposer_slashing_proposal_epoch_mismatch() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::<MainnetEthSpec>(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.message.slot = Slot::new(0); proposer_slashing.signed_header_2.message.slot = Slot::new(128); @@ -1019,92 +979,6 @@ async fn invalid_proposer_slashing_proposal_epoch_mismatch() { ); } -#[tokio::test] -async fn fork_spanning_exit() { - let mut spec = MainnetEthSpec::default_spec(); - let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); - - spec.altair_fork_epoch = Some(Epoch::new(2)); - spec.bellatrix_fork_epoch = Some(Epoch::new(4)); - spec.shard_committee_period = 0; - let spec = Arc::new(spec); - - let harness = BeaconChainHarness::builder(MainnetEthSpec) - .spec(spec.clone()) - .deterministic_keypairs(VALIDATOR_COUNT) - .mock_execution_layer() - .fresh_ephemeral_store() - .build(); - - harness.extend_to_slot(slots_per_epoch.into()).await; - - /* - * Produce an exit *before* Altair. - */ - - let signed_exit = harness.make_voluntary_exit(0, Epoch::new(1)); - assert!(signed_exit.message.epoch < spec.altair_fork_epoch.unwrap()); - - /* - * Ensure the exit verifies before Altair. - */ - - let head = harness.chain.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - assert!(head_state.current_epoch() < spec.altair_fork_epoch.unwrap()); - verify_exit( - head_state, - None, - &signed_exit, - VerifySignatures::True, - &spec, - ) - .expect("phase0 exit verifies against phase0 state"); - - /* - * Ensure the exit verifies after Altair. - */ - - harness - .extend_to_slot(spec.altair_fork_epoch.unwrap().start_slot(slots_per_epoch)) - .await; - let head = harness.chain.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - assert!(head_state.current_epoch() >= spec.altair_fork_epoch.unwrap()); - assert!(head_state.current_epoch() < spec.bellatrix_fork_epoch.unwrap()); - verify_exit( - head_state, - None, - &signed_exit, - VerifySignatures::True, - &spec, - ) - .expect("phase0 exit verifies against altair state"); - - /* - * Ensure the exit no longer verifies after Bellatrix. - */ - - harness - .extend_to_slot( - spec.bellatrix_fork_epoch - .unwrap() - .start_slot(slots_per_epoch), - ) - .await; - let head = harness.chain.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - assert!(head_state.current_epoch() >= spec.bellatrix_fork_epoch.unwrap()); - verify_exit( - head_state, - None, - &signed_exit, - VerifySignatures::True, - &spec, - ) - .expect_err("phase0 exit does not verify against bellatrix state"); -} - /// Check that the block replayer does not consume state roots unnecessarily. #[tokio::test] async fn block_replayer_peeking_state_roots() { diff --git a/consensus/state_processing/src/per_block_processing/verify_attestation.rs b/consensus/state_processing/src/per_block_processing/verify_attestation.rs index 0d1fd17768..64b7a31afb 100644 --- a/consensus/state_processing/src/per_block_processing/verify_attestation.rs +++ b/consensus/state_processing/src/per_block_processing/verify_attestation.rs @@ -52,8 +52,6 @@ pub fn verify_attestation_for_block_inclusion<'ctxt, E: EthSpec>( /// /// Returns a descriptive `Err` if the attestation is malformed or does not accurately reflect the /// prior blocks in `state`. -/// -/// Spec v0.12.1 pub fn verify_attestation_for_state<'ctxt, E: EthSpec>( state: &BeaconState<E>, attestation: AttestationRef<'ctxt, E>, @@ -74,7 +72,12 @@ pub fn verify_attestation_for_state<'ctxt, E: EthSpec>( ); } AttestationRef::Electra(_) => { - verify!(data.index == 0, Invalid::BadCommitteeIndex); + let fork_at_attestation_slot = spec.fork_name_at_slot::<E>(data.slot); + if fork_at_attestation_slot.gloas_enabled() { + verify!(data.index < 2, Invalid::BadOverloadedDataIndex); + } else { + verify!(data.index == 0, Invalid::BadCommitteeIndex); + } } } @@ -89,8 +92,6 @@ pub fn verify_attestation_for_state<'ctxt, E: EthSpec>( } /// Check target epoch and source checkpoint. -/// -/// Spec v0.12.1 fn verify_casper_ffg_vote<E: EthSpec>( attestation: AttestationRef<E>, state: &BeaconState<E>, diff --git a/consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs b/consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs new file mode 100644 index 0000000000..1c15e1c21c --- /dev/null +++ b/consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs @@ -0,0 +1,46 @@ +use super::VerifySignatures; +use super::errors::{BlockOperationError, PayloadAttestationInvalid as Invalid}; +use crate::ConsensusContext; +use crate::per_block_processing::is_valid_indexed_payload_attestation; +use safe_arith::SafeArith; +use types::*; + +pub fn verify_payload_attestation<'ctxt, E: EthSpec>( + state: &mut BeaconState<E>, + payload_attestation: &'ctxt PayloadAttestation<E>, + ctxt: &'ctxt mut ConsensusContext<E>, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError<Invalid>> { + let data = &payload_attestation.data; + + // Check that the attestation is for the parent beacon block + verify!( + data.beacon_block_root == state.latest_block_header().parent_root, + Invalid::BlockRootMismatch { + expected: state.latest_block_header().parent_root, + found: data.beacon_block_root, + } + ); + + // Check that the attestation is for the previous slot + verify!( + data.slot.safe_add(1)? == state.slot(), + Invalid::SlotMismatch { + expected: state.slot().saturating_sub(Slot::new(1)), + found: data.slot, + } + ); + + let indexed_payload_attestation = + ctxt.get_indexed_payload_attestation(state, payload_attestation, spec)?; + + is_valid_indexed_payload_attestation( + state, + indexed_payload_attestation, + verify_signatures, + spec, + )?; + + Ok(()) +} diff --git a/consensus/state_processing/src/per_block_processing/withdrawals.rs b/consensus/state_processing/src/per_block_processing/withdrawals.rs new file mode 100644 index 0000000000..8a09e35cdf --- /dev/null +++ b/consensus/state_processing/src/per_block_processing/withdrawals.rs @@ -0,0 +1,536 @@ +use crate::common::decrease_balance; +use crate::per_block_processing::builder::{ + convert_builder_index_to_validator_index, convert_validator_index_to_builder_index, + is_builder_index, +}; +use crate::per_block_processing::errors::BlockProcessingError; +use milhouse::List; +use safe_arith::{SafeArith, SafeArithIter}; +use tree_hash::TreeHash; +use types::{ + AbstractExecPayload, BeaconState, BeaconStateError, ChainSpec, EthSpec, ExecPayload, + ExpectedWithdrawals, ExpectedWithdrawalsCapella, ExpectedWithdrawalsElectra, + ExpectedWithdrawalsGloas, Validator, Withdrawal, Withdrawals, +}; + +/// Compute the next batch of withdrawals which should be included in a block. +/// +/// https://ethereum.github.io/consensus-specs/specs/gloas/beacon-chain/#modified-get_expected_withdrawals +#[allow(clippy::type_complexity)] +pub fn get_expected_withdrawals<E: EthSpec>( + state: &BeaconState<E>, + spec: &ChainSpec, +) -> Result<ExpectedWithdrawals<E>, BlockProcessingError> { + let mut withdrawal_index = state.next_withdrawal_index()?; + let mut withdrawals = Vec::<Withdrawal>::with_capacity(E::max_withdrawals_per_payload()); + + // [New in Gloas:EIP7732] + // Get builder withdrawals + let processed_builder_withdrawals_count = + get_builder_withdrawals(state, &mut withdrawal_index, &mut withdrawals)?; + + // [New in Electra:EIP7251] + // Get partial withdrawals. + let processed_partial_withdrawals_count = + get_pending_partial_withdrawals(state, &mut withdrawal_index, &mut withdrawals, spec)?; + + // [New in Gloas:EIP7732] + // Get builders sweep withdrawals + let processed_builders_sweep_count = + get_builders_sweep_withdrawals(state, &mut withdrawal_index, &mut withdrawals)?; + + // Get validators sweep withdrawals + let processed_sweep_withdrawals_count = + get_validators_sweep_withdrawals(state, &mut withdrawal_index, &mut withdrawals, spec)?; + + let withdrawals = withdrawals + .try_into() + .map_err(BlockProcessingError::SszTypesError)?; + + let fork_name = state.fork_name_unchecked(); + if fork_name.gloas_enabled() { + Ok(ExpectedWithdrawals::Gloas(ExpectedWithdrawalsGloas { + withdrawals, + processed_builder_withdrawals_count: processed_builder_withdrawals_count + .ok_or(BlockProcessingError::IncorrectExpectedWithdrawalsVariant)?, + processed_partial_withdrawals_count: processed_partial_withdrawals_count + .ok_or(BlockProcessingError::IncorrectExpectedWithdrawalsVariant)?, + processed_builders_sweep_count: processed_builders_sweep_count + .ok_or(BlockProcessingError::IncorrectExpectedWithdrawalsVariant)?, + processed_sweep_withdrawals_count, + })) + } else if fork_name.electra_enabled() { + Ok(ExpectedWithdrawals::Electra(ExpectedWithdrawalsElectra { + withdrawals, + processed_partial_withdrawals_count: processed_partial_withdrawals_count + .ok_or(BlockProcessingError::IncorrectExpectedWithdrawalsVariant)?, + processed_sweep_withdrawals_count, + })) + } else { + Ok(ExpectedWithdrawals::Capella(ExpectedWithdrawalsCapella { + withdrawals, + processed_sweep_withdrawals_count, + })) + } +} + +pub fn get_builder_withdrawals<E: EthSpec>( + state: &BeaconState<E>, + withdrawal_index: &mut u64, + withdrawals: &mut Vec<Withdrawal>, +) -> Result<Option<u64>, BlockProcessingError> { + let Ok(builder_pending_withdrawals) = state.builder_pending_withdrawals() else { + // Pre-Gloas, nothing to do. + return Ok(None); + }; + + let withdrawals_limit = E::max_withdrawals_per_payload().safe_sub(1)?; + + block_verify!( + withdrawals.len() <= withdrawals_limit, + BlockProcessingError::WithdrawalsLimitExceeded { + limit: withdrawals_limit, + prior_withdrawals: withdrawals.len() + } + ); + + let mut processed_count = 0; + for withdrawal in builder_pending_withdrawals { + let has_reached_limit = withdrawals.len() == withdrawals_limit; + + if has_reached_limit { + break; + } + + let builder_index = withdrawal.builder_index; + + withdrawals.push(Withdrawal { + index: *withdrawal_index, + validator_index: convert_builder_index_to_validator_index(builder_index), + address: withdrawal.fee_recipient, + amount: withdrawal.amount, + }); + withdrawal_index.safe_add_assign(1)?; + processed_count.safe_add_assign(1)?; + } + Ok(Some(processed_count)) +} + +pub fn get_pending_partial_withdrawals<E: EthSpec>( + state: &BeaconState<E>, + withdrawal_index: &mut u64, + withdrawals: &mut Vec<Withdrawal>, + spec: &ChainSpec, +) -> Result<Option<u64>, BlockProcessingError> { + let Ok(pending_partial_withdrawals) = state.pending_partial_withdrawals() else { + // Pre-Electra nothing to do. + return Ok(None); + }; + let epoch = state.current_epoch(); + + let withdrawals_limit = std::cmp::min( + withdrawals + .len() + .safe_add(spec.max_pending_partials_per_withdrawals_sweep as usize)?, + E::max_withdrawals_per_payload().safe_sub(1)?, + ); + + block_verify!( + withdrawals.len() <= withdrawals_limit, + BlockProcessingError::WithdrawalsLimitExceeded { + limit: withdrawals_limit, + prior_withdrawals: withdrawals.len() + } + ); + + let mut processed_count = 0; + for withdrawal in pending_partial_withdrawals { + let is_withdrawable = withdrawal.withdrawable_epoch <= epoch; + let has_reached_limit = withdrawals.len() >= withdrawals_limit; + + if !is_withdrawable || has_reached_limit { + break; + } + + let validator_index = withdrawal.validator_index; + let validator = state.get_validator(validator_index as usize)?; + let balance = get_balance_after_withdrawals(state, validator_index, withdrawals)?; + + if is_eligible_for_partial_withdrawals(validator, balance, spec) { + let withdrawal_amount = std::cmp::min( + balance.safe_sub(spec.min_activation_balance)?, + withdrawal.amount, + ); + withdrawals.push(Withdrawal { + index: *withdrawal_index, + validator_index, + address: validator + .get_execution_withdrawal_address(spec) + .ok_or(BeaconStateError::NonExecutionAddressWithdrawalCredential)?, + amount: withdrawal_amount, + }); + withdrawal_index.safe_add_assign(1)?; + } + processed_count.safe_add_assign(1)?; + } + + Ok(Some(processed_count)) +} + +/// Get withdrawals from the builders sweep. +/// +/// This function iterates through builders starting from `next_withdrawal_builder_index` +/// and adds withdrawals for builders whose withdrawable_epoch has been reached and have balance. +/// +/// https://ethereum.github.io/consensus-specs/specs/gloas/beacon-chain/#new-get_builders_sweep_withdrawals +pub fn get_builders_sweep_withdrawals<E: EthSpec>( + state: &BeaconState<E>, + withdrawal_index: &mut u64, + withdrawals: &mut Vec<Withdrawal>, +) -> Result<Option<u64>, BlockProcessingError> { + let Ok(builders) = state.builders() else { + // Pre-Gloas, nothing to do. + return Ok(None); + }; + + if builders.is_empty() { + return Ok(Some(0)); + } + + let epoch = state.current_epoch(); + let builders_limit = std::cmp::min(builders.len(), E::max_builders_per_withdrawals_sweep()); + + let withdrawals_limit = E::max_withdrawals_per_payload().safe_sub(1)?; + + block_verify!( + withdrawals.len() <= withdrawals_limit, + BlockProcessingError::WithdrawalsLimitExceeded { + limit: withdrawals_limit, + prior_withdrawals: withdrawals.len() + } + ); + + let mut processed_count: u64 = 0; + let mut builder_index = state.next_withdrawal_builder_index()?; + + for _ in 0..builders_limit { + if withdrawals.len() >= withdrawals_limit { + break; + } + + let builder = builders + .get(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))?; + + if builder.withdrawable_epoch <= epoch && builder.balance > 0 { + withdrawals.push(Withdrawal { + index: *withdrawal_index, + validator_index: convert_builder_index_to_validator_index(builder_index), + address: builder.execution_address, + amount: builder.balance, + }); + withdrawal_index.safe_add_assign(1)?; + } + + builder_index = builder_index.safe_add(1)?.safe_rem(builders.len() as u64)?; + processed_count.safe_add_assign(1)?; + } + + Ok(Some(processed_count)) +} + +/// Get withdrawals from the validator sweep. +/// +/// This function iterates through validators starting from `next_withdrawal_validator_index` +/// and adds full or partial withdrawals for eligible validators. +/// +/// https://ethereum.github.io/consensus-specs/specs/capella/beacon-chain/#new-get_validators_sweep_withdrawals +pub fn get_validators_sweep_withdrawals<E: EthSpec>( + state: &BeaconState<E>, + withdrawal_index: &mut u64, + withdrawals: &mut Vec<Withdrawal>, + spec: &ChainSpec, +) -> Result<u64, BlockProcessingError> { + let epoch = state.current_epoch(); + let fork_name = state.fork_name_unchecked(); + let mut validator_index = state.next_withdrawal_validator_index()?; + let validators_limit = std::cmp::min( + state.validators().len() as u64, + spec.max_validators_per_withdrawals_sweep, + ); + let withdrawals_limit = E::max_withdrawals_per_payload(); + + // There must be at least one space reserved for validator sweep withdrawals + block_verify!( + withdrawals.len() < withdrawals_limit, + BlockProcessingError::WithdrawalsLimitExceeded { + limit: withdrawals_limit, + prior_withdrawals: withdrawals.len() + } + ); + + let mut processed_count: u64 = 0; + + for _ in 0..validators_limit { + if withdrawals.len() >= withdrawals_limit { + break; + } + + let validator = state.get_validator(validator_index as usize)?; + let balance = get_balance_after_withdrawals(state, validator_index, withdrawals)?; + + if validator.is_fully_withdrawable_validator(balance, epoch, spec, fork_name) { + withdrawals.push(Withdrawal { + index: *withdrawal_index, + validator_index, + address: validator + .get_execution_withdrawal_address(spec) + .ok_or(BlockProcessingError::WithdrawalCredentialsInvalid)?, + amount: balance, + }); + withdrawal_index.safe_add_assign(1)?; + } else if validator.is_partially_withdrawable_validator(balance, spec, fork_name) { + withdrawals.push(Withdrawal { + index: *withdrawal_index, + validator_index, + address: validator + .get_execution_withdrawal_address(spec) + .ok_or(BlockProcessingError::WithdrawalCredentialsInvalid)?, + amount: balance.safe_sub(validator.get_max_effective_balance(spec, fork_name))?, + }); + withdrawal_index.safe_add_assign(1)?; + } + + validator_index = validator_index + .safe_add(1)? + .safe_rem(state.validators().len() as u64)?; + processed_count.safe_add_assign(1)?; + } + + Ok(processed_count) +} + +pub fn get_balance_after_withdrawals<E: EthSpec>( + state: &BeaconState<E>, + validator_index: u64, + withdrawals: &[Withdrawal], +) -> Result<u64, BeaconStateError> { + let withdrawn = withdrawals + .iter() + .filter(|withdrawal| withdrawal.validator_index == validator_index) + .map(|withdrawal| withdrawal.amount) + .safe_sum()?; + state + .get_balance(validator_index as usize)? + .safe_sub(withdrawn) + .map_err(Into::into) +} + +fn is_eligible_for_partial_withdrawals( + validator: &Validator, + balance: u64, + spec: &ChainSpec, +) -> bool { + let has_sufficient_effective_balance = + validator.effective_balance >= spec.min_activation_balance; + let has_excess_balance = balance > spec.min_activation_balance; + validator.exit_epoch == spec.far_future_epoch + && has_sufficient_effective_balance + && has_excess_balance +} + +fn update_next_withdrawal_index<E: EthSpec>( + state: &mut BeaconState<E>, + withdrawals: &Withdrawals<E>, +) -> Result<(), BlockProcessingError> { + // Update the next withdrawal index if this block contained withdrawals + if let Some(latest_withdrawal) = withdrawals.last() { + *state.next_withdrawal_index_mut()? = latest_withdrawal.index.safe_add(1)?; + } + Ok(()) +} + +fn update_payload_expected_withdrawals<E: EthSpec>( + state: &mut BeaconState<E>, + withdrawals: &Withdrawals<E>, +) -> Result<(), BlockProcessingError> { + *state.payload_expected_withdrawals_mut()? = List::new(withdrawals.to_vec())?; + Ok(()) +} + +fn update_builder_pending_withdrawals<E: EthSpec>( + state: &mut BeaconState<E>, + processed_builder_withdrawals_count: u64, +) -> Result<(), BlockProcessingError> { + state + .builder_pending_withdrawals_mut()? + .pop_front(processed_builder_withdrawals_count as usize)?; + Ok(()) +} + +fn update_pending_partial_withdrawals<E: EthSpec>( + state: &mut BeaconState<E>, + processed_partial_withdrawals_count: u64, +) -> Result<(), BlockProcessingError> { + state + .pending_partial_withdrawals_mut()? + .pop_front(processed_partial_withdrawals_count as usize)?; + Ok(()) +} + +fn update_next_withdrawal_builder_index<E: EthSpec>( + state: &mut BeaconState<E>, + processed_builders_sweep_count: u64, +) -> Result<(), BlockProcessingError> { + if !state.builders()?.is_empty() { + // Update the next builder index to start the next withdrawal sweep + let next_index = state + .next_withdrawal_builder_index()? + .safe_add(processed_builders_sweep_count)?; + let next_builder_index = next_index.safe_rem(state.builders()?.len() as u64)?; + *state.next_withdrawal_builder_index_mut()? = next_builder_index; + } + Ok(()) +} + +fn update_next_withdrawal_validator_index<E: EthSpec>( + state: &mut BeaconState<E>, + withdrawals: &Withdrawals<E>, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + // Update the next validator index to start the next withdrawal sweep + if withdrawals.len() == E::max_withdrawals_per_payload() { + // Next sweep starts after the latest withdrawal's validator index + let latest_withdrawal = withdrawals + .last() + .ok_or(BlockProcessingError::MissingLastWithdrawal)?; + let next_validator_index = latest_withdrawal + .validator_index + .safe_add(1)? + .safe_rem(state.validators().len() as u64)?; + *state.next_withdrawal_validator_index_mut()? = next_validator_index; + } else { + // Advance sweep by the max length of the sweep if there was not a full set of withdrawals + let next_validator_index = state + .next_withdrawal_validator_index()? + .safe_add(spec.max_validators_per_withdrawals_sweep)? + .safe_rem(state.validators().len() as u64)?; + *state.next_withdrawal_validator_index_mut()? = next_validator_index; + } + Ok(()) +} + +pub fn apply_withdrawals<E: EthSpec>( + state: &mut BeaconState<E>, + withdrawals: &Withdrawals<E>, +) -> Result<(), BlockProcessingError> { + for withdrawal in withdrawals { + if state.fork_name_unchecked().gloas_enabled() + && is_builder_index(withdrawal.validator_index) + { + let builder_index = + convert_validator_index_to_builder_index(withdrawal.validator_index); + let builder = state + .builders_mut()? + .get_mut(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))?; + builder.balance = builder.balance.saturating_sub(withdrawal.amount); + } else { + decrease_balance( + state, + withdrawal.validator_index as usize, + withdrawal.amount, + )?; + } + } + Ok(()) +} + +pub mod capella_electra { + use super::*; + + /// Apply withdrawals to the state. + pub fn process_withdrawals<E: EthSpec, Payload: AbstractExecPayload<E>>( + state: &mut BeaconState<E>, + payload: Payload::Ref<'_>, + spec: &ChainSpec, + ) -> Result<(), BlockProcessingError> { + let expected_withdrawals = get_expected_withdrawals(state, spec)?; + + let expected_root = expected_withdrawals.withdrawals().tree_hash_root(); + let withdrawals_root = payload.withdrawals_root()?; + if expected_root != withdrawals_root { + return Err(BlockProcessingError::WithdrawalsRootMismatch { + expected: expected_root, + found: withdrawals_root, + }); + } + + // Apply expected withdrawals. + apply_withdrawals(state, expected_withdrawals.withdrawals())?; + + // [Common] Update withdrawals fields in the state + update_next_withdrawal_index(state, expected_withdrawals.withdrawals())?; + + // [New in Electra:EIP7251] + if let Ok(processed_partial_withdrawals_count) = + expected_withdrawals.processed_partial_withdrawals_count() + { + update_pending_partial_withdrawals(state, processed_partial_withdrawals_count)?; + } + + // [Common from Capella] + update_next_withdrawal_validator_index(state, expected_withdrawals.withdrawals(), spec)?; + + Ok(()) + } +} + +pub mod gloas { + use super::*; + + /// Apply withdrawals to the state. + pub fn process_withdrawals<E: EthSpec>( + state: &mut BeaconState<E>, + spec: &ChainSpec, + ) -> Result<(), BlockProcessingError> { + // Return early if the parent block is empty. + if *state.latest_block_hash()? != state.latest_execution_payload_bid()?.block_hash { + return Ok(()); + } + + let ExpectedWithdrawals::Gloas(ExpectedWithdrawalsGloas { + withdrawals, + processed_builder_withdrawals_count, + processed_partial_withdrawals_count, + processed_builders_sweep_count, + processed_sweep_withdrawals_count: _, + }) = get_expected_withdrawals(state, spec)? + else { + return Err(BlockProcessingError::IncorrectExpectedWithdrawalsVariant); + }; + + // Apply expected withdrawals. + apply_withdrawals(state, &withdrawals)?; + + // [Common] Update withdrawals fields in the state + update_next_withdrawal_index(state, &withdrawals)?; + + // [New in Gloas:EIP7732] + update_payload_expected_withdrawals(state, &withdrawals)?; + + // [New in Gloas:EIP7732] + update_builder_pending_withdrawals(state, processed_builder_withdrawals_count)?; + + // [Common from Electra] + update_pending_partial_withdrawals(state, processed_partial_withdrawals_count)?; + + // [New in Gloas:EIP7732] + update_next_withdrawal_builder_index(state, processed_builders_sweep_count)?; + + // [Common from Capella] + update_next_withdrawal_validator_index(state, &withdrawals, spec)?; + + Ok(()) + } +} diff --git a/consensus/state_processing/src/per_epoch_processing/altair.rs b/consensus/state_processing/src/per_epoch_processing/altair.rs index d9e6964730..683d92d836 100644 --- a/consensus/state_processing/src/per_epoch_processing/altair.rs +++ b/consensus/state_processing/src/per_epoch_processing/altair.rs @@ -51,8 +51,8 @@ pub fn process_epoch<E: EthSpec>( // without loss of correctness. let current_epoch_progressive_balances = state.progressive_balances_cache().clone(); let current_epoch_total_active_balance = state.get_total_active_balance()?; - let participation_summary = - process_epoch_single_pass(state, spec, SinglePassConfig::default())?; + let epoch_result = process_epoch_single_pass(state, spec, SinglePassConfig::default())?; + let participation_summary = epoch_result.summary; // Reset eth1 data votes. process_eth1_data_reset(state)?; @@ -79,6 +79,13 @@ pub fn process_epoch<E: EthSpec>( // Rotate the epoch caches to suit the epoch transition. state.advance_caches()?; + + // Install the lookahead committee cache (built during PTC window processing) as the Next + // cache. After advance_caches, the lookahead epoch becomes the Next relative epoch. + if let Some(cache) = epoch_result.lookahead_committee_cache { + state.set_committee_cache(RelativeEpoch::Next, cache)?; + } + update_progressive_balances_on_epoch_transition(state, spec)?; Ok(EpochProcessingSummary::Altair { diff --git a/consensus/state_processing/src/per_epoch_processing/altair/inactivity_updates.rs b/consensus/state_processing/src/per_epoch_processing/altair/inactivity_updates.rs index 9e8a36b6d5..34ead069f8 100644 --- a/consensus/state_processing/src/per_epoch_processing/altair/inactivity_updates.rs +++ b/consensus/state_processing/src/per_epoch_processing/altair/inactivity_updates.rs @@ -1,8 +1,7 @@ use crate::EpochProcessingError; use crate::per_epoch_processing::single_pass::{SinglePassConfig, process_epoch_single_pass}; -use types::beacon_state::BeaconState; -use types::chain_spec::ChainSpec; -use types::eth_spec::EthSpec; +use types::core::{ChainSpec, EthSpec}; +use types::state::BeaconState; /// Slow version of `process_inactivity_updates` that runs a subset of single-pass processing. /// diff --git a/consensus/state_processing/src/per_epoch_processing/altair/participation_flag_updates.rs b/consensus/state_processing/src/per_epoch_processing/altair/participation_flag_updates.rs index 5e177c5d2b..f9b6043754 100644 --- a/consensus/state_processing/src/per_epoch_processing/altair/participation_flag_updates.rs +++ b/consensus/state_processing/src/per_epoch_processing/altair/participation_flag_updates.rs @@ -1,8 +1,8 @@ use crate::EpochProcessingError; use milhouse::List; -use types::beacon_state::BeaconState; -use types::eth_spec::EthSpec; -use types::participation_flags::ParticipationFlags; +use types::attestation::ParticipationFlags; +use types::core::EthSpec; +use types::state::BeaconState; pub fn process_participation_flag_updates<E: EthSpec>( state: &mut BeaconState<E>, diff --git a/consensus/state_processing/src/per_epoch_processing/altair/sync_committee_updates.rs b/consensus/state_processing/src/per_epoch_processing/altair/sync_committee_updates.rs index 3bb2ced982..12e80d608b 100644 --- a/consensus/state_processing/src/per_epoch_processing/altair/sync_committee_updates.rs +++ b/consensus/state_processing/src/per_epoch_processing/altair/sync_committee_updates.rs @@ -1,9 +1,8 @@ use crate::EpochProcessingError; use safe_arith::SafeArith; use std::sync::Arc; -use types::beacon_state::BeaconState; -use types::chain_spec::ChainSpec; -use types::eth_spec::EthSpec; +use types::core::{ChainSpec, EthSpec}; +use types::state::BeaconState; pub fn process_sync_committee_updates<E: EthSpec>( state: &mut BeaconState<E>, diff --git a/consensus/state_processing/src/per_epoch_processing/base/participation_record_updates.rs b/consensus/state_processing/src/per_epoch_processing/base/participation_record_updates.rs index 52646e2269..74f71ad3bc 100644 --- a/consensus/state_processing/src/per_epoch_processing/base/participation_record_updates.rs +++ b/consensus/state_processing/src/per_epoch_processing/base/participation_record_updates.rs @@ -1,6 +1,6 @@ use crate::EpochProcessingError; -use types::beacon_state::BeaconState; -use types::eth_spec::EthSpec; +use types::core::EthSpec; +use types::state::BeaconState; pub fn process_participation_record_updates<E: EthSpec>( state: &mut BeaconState<E>, diff --git a/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs b/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs index c5ec80b92a..3e4f7e8189 100644 --- a/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs +++ b/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs @@ -2,7 +2,7 @@ use crate::common::attesting_indices_base::get_attesting_indices; use safe_arith::SafeArith; use types::{BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, PendingAttestation}; -#[cfg(feature = "arbitrary-fuzz")] +#[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; /// Sets the boolean `var` on `self` to be true if it is true on `other`. Otherwise leaves `self` @@ -16,7 +16,7 @@ macro_rules! set_self_if_other_is_true { } /// The information required to reward a block producer for including an attestation in a block. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq)] pub struct InclusionInfo { /// The distance between the attestation slot and the slot that attestation was included in a @@ -48,7 +48,7 @@ impl InclusionInfo { } /// Information required to reward some validator during the current and previous epoch. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(Debug, Default, Clone, PartialEq)] pub struct ValidatorStatus { /// True if the validator has been slashed, ever. @@ -118,7 +118,7 @@ impl ValidatorStatus { /// epochs. #[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] pub struct TotalBalances { /// The effective balance increment from the spec. effective_balance_increment: u64, @@ -175,7 +175,7 @@ impl TotalBalances { /// Summarised information about validator participation in the _previous and _current_ epochs of /// some `BeaconState`. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(Debug, Clone)] pub struct ValidatorStatuses { /// Information about each individual validator from the state's validator registry. diff --git a/consensus/state_processing/src/per_epoch_processing/capella/historical_summaries_update.rs b/consensus/state_processing/src/per_epoch_processing/capella/historical_summaries_update.rs index 00adabdcfe..92ba3326f5 100644 --- a/consensus/state_processing/src/per_epoch_processing/capella/historical_summaries_update.rs +++ b/consensus/state_processing/src/per_epoch_processing/capella/historical_summaries_update.rs @@ -1,6 +1,6 @@ use crate::EpochProcessingError; use safe_arith::SafeArith; -use types::historical_summary::HistoricalSummary; +use types::state::HistoricalSummary; use types::{BeaconState, EthSpec}; pub fn process_historical_summaries_update<E: EthSpec>( diff --git a/consensus/state_processing/src/per_epoch_processing/effective_balance_updates.rs b/consensus/state_processing/src/per_epoch_processing/effective_balance_updates.rs index 8daad83a15..6d00cb77ff 100644 --- a/consensus/state_processing/src/per_epoch_processing/effective_balance_updates.rs +++ b/consensus/state_processing/src/per_epoch_processing/effective_balance_updates.rs @@ -1,8 +1,8 @@ use super::errors::EpochProcessingError; use crate::per_epoch_processing::single_pass::{SinglePassConfig, process_epoch_single_pass}; use safe_arith::SafeArith; -use types::beacon_state::BeaconState; -use types::chain_spec::ChainSpec; +use types::core::ChainSpec; +use types::state::BeaconState; use types::{BeaconStateError, EthSpec}; /// This implementation is now only used in phase0. Later hard forks use single-pass. diff --git a/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs b/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs index a818e08775..3c043a65f2 100644 --- a/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs +++ b/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs @@ -4,8 +4,8 @@ use milhouse::List; use std::sync::Arc; use types::{ BeaconStateError, Epoch, EthSpec, ParticipationFlags, ProgressiveBalancesCache, SyncCommittee, - Validator, consts::altair::{TIMELY_HEAD_FLAG_INDEX, TIMELY_SOURCE_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX}, + state::Validators, }; /// Provides a summary of validator participation during the epoch. @@ -26,7 +26,7 @@ pub enum EpochProcessingSummary<E: EthSpec> { #[derive(PartialEq, Debug)] pub struct ParticipationEpochSummary<E: EthSpec> { /// Copy of the validator registry prior to mutation. - validators: List<Validator, E::ValidatorRegistryLimit>, + validators: Validators<E>, /// Copy of the participation flags for the previous epoch. previous_epoch_participation: List<ParticipationFlags, E::ValidatorRegistryLimit>, /// Copy of the participation flags for the current epoch. @@ -37,7 +37,7 @@ pub struct ParticipationEpochSummary<E: EthSpec> { impl<E: EthSpec> ParticipationEpochSummary<E> { pub fn new( - validators: List<Validator, E::ValidatorRegistryLimit>, + validators: Validators<E>, previous_epoch_participation: List<ParticipationFlags, E::ValidatorRegistryLimit>, current_epoch_participation: List<ParticipationFlags, E::ValidatorRegistryLimit>, previous_epoch: Epoch, diff --git a/consensus/state_processing/src/per_epoch_processing/historical_roots_update.rs b/consensus/state_processing/src/per_epoch_processing/historical_roots_update.rs index 9172d954bc..1bb814af05 100644 --- a/consensus/state_processing/src/per_epoch_processing/historical_roots_update.rs +++ b/consensus/state_processing/src/per_epoch_processing/historical_roots_update.rs @@ -2,8 +2,8 @@ use super::errors::EpochProcessingError; use safe_arith::SafeArith; use tree_hash::TreeHash; use typenum::Unsigned; -use types::beacon_state::BeaconState; -use types::eth_spec::EthSpec; +use types::core::EthSpec; +use types::state::BeaconState; pub fn process_historical_roots_update<E: EthSpec>( state: &mut BeaconState<E>, diff --git a/consensus/state_processing/src/per_epoch_processing/resets.rs b/consensus/state_processing/src/per_epoch_processing/resets.rs index e05fb30c33..c00cb7d635 100644 --- a/consensus/state_processing/src/per_epoch_processing/resets.rs +++ b/consensus/state_processing/src/per_epoch_processing/resets.rs @@ -2,8 +2,8 @@ use super::errors::EpochProcessingError; use milhouse::List; use safe_arith::SafeArith; use typenum::Unsigned; -use types::beacon_state::BeaconState; -use types::eth_spec::EthSpec; +use types::core::EthSpec; +use types::state::BeaconState; pub fn process_eth1_data_reset<E: EthSpec>( state: &mut BeaconState<E>, diff --git a/consensus/state_processing/src/per_epoch_processing/single_pass.rs b/consensus/state_processing/src/per_epoch_processing/single_pass.rs index 914e025f2f..881e6bb16c 100644 --- a/consensus/state_processing/src/per_epoch_processing/single_pass.rs +++ b/consensus/state_processing/src/per_epoch_processing/single_pass.rs @@ -12,12 +12,13 @@ use milhouse::{Cow, List, Vector}; use safe_arith::{SafeArith, SafeArithIter}; use std::cmp::{max, min}; use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; use tracing::instrument; use typenum::Unsigned; use types::{ - ActivationQueue, BeaconState, BeaconStateError, ChainSpec, Checkpoint, DepositData, Epoch, - EthSpec, ExitCache, ForkName, ParticipationFlags, PendingDeposit, ProgressiveBalancesCache, - RelativeEpoch, Validator, + ActivationQueue, BeaconState, BeaconStateError, BuilderPendingPayment, ChainSpec, Checkpoint, + CommitteeCache, DepositData, Epoch, EthSpec, ExitCache, ForkName, ParticipationFlags, + PendingDeposit, ProgressiveBalancesCache, RelativeEpoch, Validator, consts::altair::{ NUM_FLAG_INDICES, PARTICIPATION_FLAG_WEIGHTS, TIMELY_HEAD_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX, WEIGHT_DENOMINATOR, @@ -33,6 +34,8 @@ pub struct SinglePassConfig { pub pending_consolidations: bool, pub effective_balance_updates: bool, pub proposer_lookahead: bool, + pub builder_pending_payments: bool, + pub ptc_window: bool, } impl Default for SinglePassConfig { @@ -52,6 +55,8 @@ impl SinglePassConfig { pending_consolidations: true, effective_balance_updates: true, proposer_lookahead: true, + builder_pending_payments: true, + ptc_window: true, } } @@ -65,6 +70,8 @@ impl SinglePassConfig { pending_consolidations: false, effective_balance_updates: false, proposer_lookahead: false, + builder_pending_payments: false, + ptc_window: false, } } } @@ -136,12 +143,20 @@ impl ValidatorInfo { } } +/// Result of single-pass epoch processing. +pub struct SinglePassEpochResult<E: EthSpec> { + pub summary: ParticipationEpochSummary<E>, + /// Committee cache for the lookahead epoch, built during PTC window processing. + /// Can be installed as the Next committee cache after `advance_caches`. + pub lookahead_committee_cache: Option<Arc<CommitteeCache>>, +} + #[instrument(skip_all)] pub fn process_epoch_single_pass<E: EthSpec>( state: &mut BeaconState<E>, spec: &ChainSpec, conf: SinglePassConfig, -) -> Result<ParticipationEpochSummary<E>, Error> { +) -> Result<SinglePassEpochResult<E>, Error> { initialize_epoch_cache(state, spec)?; initialize_progressive_balances_cache(state, spec)?; state.build_exit_cache(spec)?; @@ -455,6 +470,12 @@ pub fn process_epoch_single_pass<E: EthSpec>( )?; } + // Process builder pending payments outside the single-pass loop, as they depend on balances for + // multiple validators and cannot be computed accurately inside the loop. + if fork_name.gloas_enabled() && conf.builder_pending_payments { + process_builder_pending_payments(state, state_ctxt, spec)?; + } + // Finally, finish updating effective balance caches. We need this to happen *after* processing // of pending consolidations, which recomputes some effective balances. if conf.effective_balance_updates { @@ -470,7 +491,16 @@ pub fn process_epoch_single_pass<E: EthSpec>( process_proposer_lookahead(state, spec)?; } - Ok(summary) + let lookahead_committee_cache = if conf.ptc_window && fork_name.gloas_enabled() { + Some(process_ptc_window(state, spec)?) + } else { + None + }; + + Ok(SinglePassEpochResult { + summary, + lookahead_committee_cache, + }) } // TOOO(EIP-7917): use balances cache @@ -503,6 +533,105 @@ pub fn process_proposer_lookahead<E: EthSpec>( Ok(()) } +/// Process the PTC window, returning the committee cache built for the lookahead epoch. +/// +/// The returned cache can be injected into the state's Next committee cache slot after +/// `advance_caches` is called during the epoch transition, avoiding redundant recomputation. +pub fn process_ptc_window<E: EthSpec>( + state: &mut BeaconState<E>, + spec: &ChainSpec, +) -> Result<Arc<CommitteeCache>, Error> { + let slots_per_epoch = E::slots_per_epoch() as usize; + + // Convert Vector -> List to use tree-efficient pop_front. + let ptc_window = state.ptc_window()?.clone(); + let mut window: List<_, E::PtcWindowLength> = List::from(ptc_window); + + // Drop the oldest epoch from the front (reuses shared tree nodes). + window + .pop_front(slots_per_epoch) + .map_err(|e| Error::BeaconStateError(BeaconStateError::MilhouseError(e)))?; + + // Compute PTC for the new lookahead epoch + let next_epoch = state + .current_epoch() + .safe_add(spec.min_seed_lookahead.as_u64())? + .safe_add(1)?; + let start_slot = next_epoch.start_slot(E::slots_per_epoch()); + + // Build a committee cache for the lookahead epoch (beyond the normal Next bound) + let committee_cache = state.initialize_committee_cache_for_lookahead(next_epoch, spec)?; + + for i in 0..slots_per_epoch { + let slot = start_slot.safe_add(i as u64)?; + let ptc = state.compute_ptc_with_cache(slot, &committee_cache, spec)?; + let ptc_u64: Vec<u64> = ptc.into_iter().map(|v| v as u64).collect(); + let entry = ssz_types::FixedVector::new(ptc_u64) + .map_err(|e| Error::BeaconStateError(BeaconStateError::SszTypesError(e)))?; + window + .push(entry) + .map_err(|e| Error::BeaconStateError(BeaconStateError::MilhouseError(e)))?; + } + + // Convert List back to Vector. + *state.ptc_window_mut()? = Vector::try_from(window) + .map_err(|e| Error::BeaconStateError(BeaconStateError::MilhouseError(e)))?; + + Ok(committee_cache) +} + +/// Calculate the quorum threshold for builder payments based on total active balance. +fn get_builder_payment_quorum_threshold<E: EthSpec>( + state_ctxt: &StateContext, + spec: &ChainSpec, +) -> Result<u64, Error> { + let per_slot_balance = state_ctxt + .total_active_balance + .safe_div(E::slots_per_epoch())?; + let quorum = per_slot_balance.safe_mul(spec.builder_payment_threshold_numerator)?; + quorum + .safe_div(spec.builder_payment_threshold_denominator) + .map_err(Error::from) +} + +/// Processes the builder pending payments from the previous epoch. +fn process_builder_pending_payments<E: EthSpec>( + state: &mut BeaconState<E>, + state_ctxt: &StateContext, + spec: &ChainSpec, +) -> Result<(), Error> { + let quorum = get_builder_payment_quorum_threshold::<E>(state_ctxt, spec)?; + + // Collect qualifying payments and append to `builder_pending_withdrawals`. + // We use this pattern rather than a loop to avoid multiple borrows of the state's fields. + let new_pending_builder_withdrawals = state + .builder_pending_payments()? + .iter() + .take(E::SlotsPerEpoch::to_usize()) + .filter(|payment| payment.weight >= quorum) + .map(|payment| payment.withdrawal.clone()) + .collect::<Vec<_>>(); + for payment_withdrawal in new_pending_builder_withdrawals { + state + .builder_pending_withdrawals_mut()? + .push(payment_withdrawal)?; + } + + // NOTE: this could be a little more memory-efficient with some juggling to reuse parts + // of the persistent tree (could convert to list, use pop_front, convert back). + let updated_payments = state + .builder_pending_payments()? + .iter() + .skip(E::SlotsPerEpoch::to_usize()) + .cloned() + .chain((0..E::SlotsPerEpoch::to_usize()).map(|_| BuilderPendingPayment::default())) + .collect::<Vec<_>>(); + + *state.builder_pending_payments_mut()? = Vector::new(updated_payments)?; + + Ok(()) +} + fn process_single_inactivity_update( inactivity_score: &mut Cow<u64>, validator_info: &ValidatorInfo, @@ -833,7 +962,11 @@ fn compute_exit_epoch_and_update_churn( spec.compute_activation_exit_epoch(state_ctxt.current_epoch)?, ); - let per_epoch_churn = get_activation_exit_churn_limit(state_ctxt, spec)?; + let per_epoch_churn = if state_ctxt.fork_name.gloas_enabled() { + get_balance_churn_limit(state_ctxt, spec)? + } else { + get_activation_exit_churn_limit(state_ctxt, spec)? + }; // New epoch for exits let mut exit_balance_to_consume = if *earliest_exit_epoch_state < earliest_exit_epoch { per_epoch_churn @@ -862,17 +995,27 @@ fn get_activation_exit_churn_limit( state_ctxt: &StateContext, spec: &ChainSpec, ) -> Result<u64, Error> { + let max_limit = if state_ctxt.fork_name.gloas_enabled() { + spec.max_per_epoch_activation_churn_limit_gloas + } else { + spec.max_per_epoch_activation_exit_churn_limit + }; Ok(std::cmp::min( - spec.max_per_epoch_activation_exit_churn_limit, + max_limit, get_balance_churn_limit(state_ctxt, spec)?, )) } fn get_balance_churn_limit(state_ctxt: &StateContext, spec: &ChainSpec) -> Result<u64, Error> { let total_active_balance = state_ctxt.total_active_balance; + let quotient = if state_ctxt.fork_name.gloas_enabled() { + spec.churn_limit_quotient_gloas + } else { + spec.churn_limit_quotient + }; let churn = std::cmp::max( spec.min_per_epoch_churn_limit_electra, - total_active_balance.safe_div(spec.churn_limit_quotient)?, + total_active_balance.safe_div(quotient)?, ); Ok(churn.safe_sub(churn.safe_rem(spec.effective_balance_increment)?)?) @@ -886,7 +1029,7 @@ impl SlashingsContext { ) -> Result<Self, Error> { let sum_slashings = state.get_all_slashings().iter().copied().safe_sum()?; let adjusted_total_slashing_balance = min( - sum_slashings.safe_mul(spec.proportional_slashing_multiplier_for_state(state))?, + sum_slashings.safe_mul(state.get_proportional_slashing_multiplier(spec))?, state_ctxt.total_active_balance, ); diff --git a/consensus/state_processing/src/per_epoch_processing/slashings.rs b/consensus/state_processing/src/per_epoch_processing/slashings.rs index 6008276d15..cf5e82663c 100644 --- a/consensus/state_processing/src/per_epoch_processing/slashings.rs +++ b/consensus/state_processing/src/per_epoch_processing/slashings.rs @@ -17,7 +17,7 @@ pub fn process_slashings<E: EthSpec>( let sum_slashings = state.get_all_slashings().iter().copied().safe_sum()?; let adjusted_total_slashing_balance = std::cmp::min( - sum_slashings.safe_mul(spec.proportional_slashing_multiplier_for_state(state))?, + sum_slashings.safe_mul(state.get_proportional_slashing_multiplier(spec))?, total_balance, ); diff --git a/consensus/state_processing/src/per_epoch_processing/tests.rs b/consensus/state_processing/src/per_epoch_processing/tests.rs index f042e8766c..c04b7f843d 100644 --- a/consensus/state_processing/src/per_epoch_processing/tests.rs +++ b/consensus/state_processing/src/per_epoch_processing/tests.rs @@ -11,10 +11,10 @@ async fn runs_without_error() { .default_spec() .deterministic_keypairs(8) .fresh_ephemeral_store() + .mock_execution_layer() .build(); harness.advance_slot(); - let spec = MinimalEthSpec::default_spec(); let target_slot = (MinimalEthSpec::genesis_epoch() + 4).end_slot(MinimalEthSpec::slots_per_epoch()); @@ -32,7 +32,7 @@ async fn runs_without_error() { .await; let mut new_head_state = harness.get_current_state(); - process_epoch(&mut new_head_state, &spec).unwrap(); + process_epoch(&mut new_head_state, &harness.spec).unwrap(); } #[cfg(not(debug_assertions))] diff --git a/consensus/state_processing/src/per_slot_processing.rs b/consensus/state_processing/src/per_slot_processing.rs index a8dc8c389f..77adb4c7d1 100644 --- a/consensus/state_processing/src/per_slot_processing.rs +++ b/consensus/state_processing/src/per_slot_processing.rs @@ -14,6 +14,7 @@ pub enum Error { EpochProcessingError(EpochProcessingError), ArithError(ArithError), InconsistentStateFork(InconsistentFork), + BitfieldError(ssz::BitfieldError), } impl From<ArithError> for Error { @@ -22,6 +23,12 @@ impl From<ArithError> for Error { } } +impl From<ssz::BitfieldError> for Error { + fn from(e: ssz::BitfieldError) -> Self { + Self::BitfieldError(e) + } +} + /// Advances a state forward by one slot, performing per-epoch processing if required. /// /// If the root of the supplied `state` is known, then it can be passed as `state_root`. If @@ -48,6 +55,18 @@ pub fn per_slot_processing<E: EthSpec>( None }; + // Unset the next payload availability + if state.fork_name_unchecked().gloas_enabled() { + let next_slot_index = state + .slot() + .as_usize() + .safe_add(1)? + .safe_rem(E::slots_per_historical_root())?; + state + .execution_payload_availability_mut()? + .set(next_slot_index, false)?; + } + state.slot_mut().safe_add_assign(1)?; // Process fork upgrades here. Note that multiple upgrades can potentially run diff --git a/consensus/state_processing/src/state_advance.rs b/consensus/state_processing/src/state_advance.rs index 19b21dad19..1114562155 100644 --- a/consensus/state_processing/src/state_advance.rs +++ b/consensus/state_processing/src/state_advance.rs @@ -6,6 +6,7 @@ use crate::*; use fixed_bytes::FixedBytesExtended; +use tracing::instrument; use types::{BeaconState, ChainSpec, EthSpec, Hash256, Slot}; #[derive(Debug, PartialEq)] @@ -59,6 +60,7 @@ pub fn complete_state_advance<E: EthSpec>( /// /// - If `state.slot > target_slot`, an error will be returned. /// - If `state_root_opt.is_none()` but the latest block header requires a state root. +#[instrument(skip_all, level = "debug")] pub fn partial_state_advance<E: EthSpec>( state: &mut BeaconState<E>, state_root_opt: Option<Hash256>, @@ -75,6 +77,11 @@ pub fn partial_state_advance<E: EthSpec>( // (all-zeros) state root. let mut initial_state_root = Some(if state.slot() > state.latest_block_header().slot { state_root_opt.unwrap_or_else(Hash256::zero) + } else if state.slot() == state.latest_block_header().slot + && !state.latest_block_header().state_root.is_zero() + { + // Post-Gloas Full state case. + state.latest_block_header().state_root } else { state_root_opt.ok_or(Error::StateRootNotProvided)? }); diff --git a/consensus/state_processing/src/upgrade/gloas.rs b/consensus/state_processing/src/upgrade/gloas.rs index d6c353cc2a..84cdbf22c2 100644 --- a/consensus/state_processing/src/upgrade/gloas.rs +++ b/consensus/state_processing/src/upgrade/gloas.rs @@ -1,10 +1,18 @@ -use bls::Hash256; +use crate::per_block_processing::{ + is_valid_deposit_signature, process_operations::apply_deposit_for_builder, +}; use milhouse::{List, Vector}; +use safe_arith::SafeArith; use ssz_types::BitVector; +use ssz_types::FixedVector; +use std::collections::HashSet; use std::mem; +use tree_hash::TreeHash; +use typenum::Unsigned; use types::{ BeaconState, BeaconStateError as Error, BeaconStateGloas, BuilderPendingPayment, ChainSpec, - EthSpec, ExecutionPayloadBid, Fork, + DepositData, EthSpec, ExecutionPayloadBid, ExecutionRequests, Fork, + is_builder_withdrawal_credential, }; /// Transform a `Fulu` state into a `Gloas` state. @@ -30,7 +38,7 @@ pub fn upgrade_state_to_gloas<E: EthSpec>( // // Fixed size vectors get cloned because replacing them would require the same size // allocation as cloning. - let post = BeaconState::Gloas(BeaconStateGloas { + let mut post = BeaconState::Gloas(BeaconStateGloas { // Versioning genesis_time: pre.genesis_time, genesis_validators_root: pre.genesis_validators_root, @@ -70,7 +78,11 @@ pub fn upgrade_state_to_gloas<E: EthSpec>( current_sync_committee: pre.current_sync_committee.clone(), next_sync_committee: pre.next_sync_committee.clone(), // Execution Bid - latest_execution_payload_bid: ExecutionPayloadBid::default(), + latest_execution_payload_bid: ExecutionPayloadBid { + block_hash: pre.latest_execution_payload_header.block_hash, + execution_requests_root: ExecutionRequests::<E>::default().tree_hash_root(), + ..Default::default() + }, // Capella next_withdrawal_index: pre.next_withdrawal_index, next_withdrawal_validator_index: pre.next_withdrawal_validator_index, @@ -85,15 +97,21 @@ pub fn upgrade_state_to_gloas<E: EthSpec>( pending_deposits: pre.pending_deposits.clone(), pending_partial_withdrawals: pre.pending_partial_withdrawals.clone(), pending_consolidations: pre.pending_consolidations.clone(), + proposer_lookahead: mem::take(&mut pre.proposer_lookahead), // Gloas - execution_payload_availability: BitVector::default(), // All bits set to false initially - builder_pending_payments: Vector::new(vec![ - BuilderPendingPayment::default(); - E::builder_pending_payments_limit() - ])?, + builders: List::default(), + next_withdrawal_builder_index: 0, + // All bits set to true per spec: + // execution_payload_availability = [0b1 for _ in range(SLOTS_PER_HISTORICAL_ROOT)] + execution_payload_availability: BitVector::from_bytes( + vec![0xFFu8; E::SlotsPerHistoricalRoot::to_usize() / 8].into(), + ) + .map_err(|_| Error::InvalidBitfield)?, + builder_pending_payments: Vector::from_elem(BuilderPendingPayment::default())?, builder_pending_withdrawals: List::default(), // Empty list initially, latest_block_hash: pre.latest_execution_payload_header.block_hash, - latest_withdrawals_root: Hash256::default(), + payload_expected_withdrawals: List::default(), + ptc_window: Vector::from_elem(FixedVector::from_elem(0))?, // placeholder, will be initialized below // Caches total_active_balance: pre.total_active_balance, progressive_balances_cache: mem::take(&mut pre.progressive_balances_cache), @@ -102,7 +120,117 @@ pub fn upgrade_state_to_gloas<E: EthSpec>( exit_cache: mem::take(&mut pre.exit_cache), slashings_cache: mem::take(&mut pre.slashings_cache), epoch_cache: mem::take(&mut pre.epoch_cache), - proposer_lookahead: mem::take(&mut pre.proposer_lookahead), }); + // [New in Gloas:EIP7732] + onboard_builders_from_pending_deposits(&mut post, spec)?; + initialize_ptc_window(&mut post, spec)?; + Ok(post) } + +/// Initialize the `ptc_window` field in the beacon state at fork transition. +/// +/// The window contains: +/// - One epoch of empty entries (previous epoch) +/// - Computed PTC for the current epoch through `1 + MIN_SEED_LOOKAHEAD` epochs +fn initialize_ptc_window<E: EthSpec>( + state: &mut BeaconState<E>, + spec: &ChainSpec, +) -> Result<(), Error> { + let slots_per_epoch = E::slots_per_epoch() as usize; + + let empty_previous_epoch = vec![FixedVector::<u64, E::PTCSize>::from_elem(0); slots_per_epoch]; + let mut ptcs = empty_previous_epoch; + + // Compute PTC for current epoch + lookahead epochs + let current_epoch = state.current_epoch(); + for e in 0..=spec.min_seed_lookahead.as_u64() { + let epoch = current_epoch.safe_add(e)?; + let committee_cache = state.initialize_committee_cache_for_lookahead(epoch, spec)?; + let start_slot = epoch.start_slot(E::slots_per_epoch()); + for i in 0..slots_per_epoch { + let slot = start_slot.safe_add(i as u64)?; + let ptc = state.compute_ptc_with_cache(slot, &committee_cache, spec)?; + let ptc_u64: Vec<u64> = ptc.into_iter().map(|v| v as u64).collect(); + let entry = FixedVector::new(ptc_u64)?; + ptcs.push(entry); + } + } + + *state.ptc_window_mut()? = Vector::new(ptcs)?; + + Ok(()) +} + +/// Applies any pending deposit for builders, effectively onboarding builders at the fork. +fn onboard_builders_from_pending_deposits<E: EthSpec>( + state: &mut BeaconState<E>, + spec: &ChainSpec, +) -> Result<(), Error> { + // Rather than tracking all `validator_pubkeys` in one place as the spec does, we keep a + // hashset for *just* the new validator pubkeys, and use the state's efficient + // `get_validator_index` function instead of an O(n) iteration over the full validator list. + let mut new_validator_pubkeys = HashSet::new(); + + // Clone pending deposits to avoid borrow conflicts when mutating state. + let current_pending_deposits = state.pending_deposits()?.clone(); + + let mut pending_deposits = List::empty(); + + for deposit in ¤t_pending_deposits { + // Deposits for existing validators stay in the pending queue. + if new_validator_pubkeys.contains(&deposit.pubkey) + || state.get_validator_index(&deposit.pubkey)?.is_some() + { + pending_deposits.push(deposit.clone())?; + continue; + } + + // Re-scan builder list each iteration because `apply_deposit_for_builder` may add + // new builders to the registry. + // TODO(gloas): this linear scan could be optimized, see: + // https://github.com/sigp/lighthouse/issues/8783 + let builder_index = state + .builders()? + .iter() + .position(|b| b.pubkey == deposit.pubkey); + + let has_builder_credentials = + is_builder_withdrawal_credential(deposit.withdrawal_credentials, spec); + + if builder_index.is_some() || has_builder_credentials { + let builder_index_opt = builder_index.map(|i| i as u64); + apply_deposit_for_builder( + state, + builder_index_opt, + deposit.pubkey, + deposit.withdrawal_credentials, + deposit.amount, + deposit.signature.clone(), + deposit.slot, + spec, + )?; + continue; + } + + // If there is a pending deposit for a new validator that has a valid signature, + // track the pubkey so that subsequent builder deposits for the same pubkey stay + // in pending (applied to the validator later) rather than creating a builder. + // Deposits with invalid signatures are dropped since they would fail in + // apply_pending_deposit anyway. + let deposit_data = DepositData { + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + signature: deposit.signature.clone(), + }; + if is_valid_deposit_signature(&deposit_data, spec).is_ok() { + new_validator_pubkeys.insert(deposit.pubkey); + pending_deposits.push(deposit.clone())?; + } + } + + *state.pending_deposits_mut()? = pending_deposits; + + Ok(()) +} diff --git a/consensus/state_processing/src/verify_operation.rs b/consensus/state_processing/src/verify_operation.rs index 1f76f19586..1e9c3d5fe3 100644 --- a/consensus/state_processing/src/verify_operation.rs +++ b/consensus/state_processing/src/verify_operation.rs @@ -7,6 +7,7 @@ use crate::per_block_processing::{ verify_attester_slashing, verify_bls_to_execution_change, verify_exit, verify_proposer_slashing, }; +#[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; use educe::Educe; use smallvec::{SmallVec, smallvec}; @@ -39,13 +40,17 @@ pub trait TransformPersist { /// /// The inner `op` field is private, meaning instances of this type can only be constructed /// by calling `validate`. -#[derive(Educe, Debug, Clone, Arbitrary)] +#[derive(Educe, Debug, Clone)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[educe( PartialEq, Eq, Hash(bound(T: TransformPersist + std::hash::Hash, E: EthSpec)) )] -#[arbitrary(bound = "T: TransformPersist + Arbitrary<'arbitrary>, E: EthSpec")] +#[cfg_attr( + feature = "arbitrary", + arbitrary(bound = "T: TransformPersist + Arbitrary<'arbitrary>, E: EthSpec") +)] pub struct SigVerifiedOp<T: TransformPersist, E: EthSpec> { op: T, verified_against: VerifiedAgainst, @@ -133,7 +138,8 @@ struct SigVerifiedOpDecode<P: Decode> { /// /// We need to store multiple `ForkVersion`s because attester slashings contain two indexed /// attestations which may be signed using different versions. -#[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode, TestRandom, Arbitrary)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode, TestRandom)] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] pub struct VerifiedAgainst { fork_versions: SmallVec<[ForkVersion; MAX_FORKS_VERIFIED_AGAINST]>, } diff --git a/consensus/swap_or_not_shuffle/benches/benches.rs b/consensus/swap_or_not_shuffle/benches/benches.rs index f33556be38..5a9ba38f06 100644 --- a/consensus/swap_or_not_shuffle/benches/benches.rs +++ b/consensus/swap_or_not_shuffle/benches/benches.rs @@ -1,4 +1,5 @@ -use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use std::hint::black_box; use swap_or_not_shuffle::{compute_shuffled_index, shuffle_list as fast_shuffle}; const SHUFFLE_ROUND_COUNT: u8 = 90; diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index 78c6f871cb..4aae4b7f39 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -1,26 +1,24 @@ [package] name = "types" version = "0.2.1" -authors = [ - "Paul Hauner <paul@paulhauner.com>", - "Age Manning <Age@AgeManning.com>", -] +authors = ["Paul Hauner <paul@paulhauner.com>", "Age Manning <Age@AgeManning.com>"] edition = { workspace = true } [features] -default = ["sqlite", "legacy-arith"] -# Allow saturating arithmetic on slots and epochs. Enabled by default, but deprecated. -legacy-arith = [] +default = [] +# Enable +, -, *, /, % operators for Slot and Epoch types. +# Operations saturate instead of wrapping. +saturating-arith = [] sqlite = ["dep:rusqlite"] arbitrary = [ "dep:arbitrary", "bls/arbitrary", + "kzg/arbitrary", "ethereum_ssz/arbitrary", "milhouse/arbitrary", "ssz_types/arbitrary", "swap_or_not_shuffle/arbitrary", ] -arbitrary-fuzz = ["arbitrary"] portable = ["bls/supranational-portable"] [dependencies] @@ -55,7 +53,6 @@ rusqlite = { workspace = true, optional = true } safe_arith = { workspace = true } serde = { workspace = true, features = ["rc"] } serde_json = { workspace = true } -serde_yaml = { workspace = true } smallvec = { workspace = true } ssz_types = { workspace = true } superstruct = { workspace = true } @@ -66,6 +63,7 @@ tracing = { workspace = true } tree_hash = { workspace = true } tree_hash_derive = { workspace = true } typenum = { workspace = true } +yaml_serde = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } diff --git a/consensus/types/benches/benches.rs b/consensus/types/benches/benches.rs index 397c33163e..85d7de980b 100644 --- a/consensus/types/benches/benches.rs +++ b/consensus/types/benches/benches.rs @@ -1,8 +1,9 @@ -use criterion::{BatchSize, BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; use fixed_bytes::FixedBytesExtended; use milhouse::List; use rayon::prelude::*; use ssz::Encode; +use std::hint::black_box; use std::sync::Arc; use types::{ BeaconState, Epoch, Eth1Data, EthSpec, Hash256, MainnetEthSpec, Validator, diff --git a/consensus/types/configs/mainnet.yaml b/consensus/types/configs/mainnet.yaml new file mode 100644 index 0000000000..25bf872a7a --- /dev/null +++ b/consensus/types/configs/mainnet.yaml @@ -0,0 +1,231 @@ +# Mainnet config + +# Extends the mainnet preset +PRESET_BASE: 'mainnet' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'mainnet' - there can be only one +# * 'sepolia' - testnet +# * 'holesky' - testnet +# * 'hoodi' - testnet +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'mainnet' + +# Transition +# --------------------------------------------------------------- +# Estimated on Sept 15, 2022 +TERMINAL_TOTAL_DIFFICULTY: 58750000000000000000000 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + +# Genesis +# --------------------------------------------------------------- +# 2**14 (= 16,384) validators +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 +# Dec 1, 2020, 12pm UTC +MIN_GENESIS_TIME: 1606824000 +# Initial fork version for mainnet +GENESIS_FORK_VERSION: 0x00000000 +# 7 * 24 * 3,600 (= 604,800) seconds, 7 days +GENESIS_DELAY: 604800 + +# Forking +# --------------------------------------------------------------- +# Some forks are disabled for now: +# - These may be re-assigned to another fork-version later +# - Temporarily set to max uint64 value: 2**64 - 1 + +# Altair +ALTAIR_FORK_VERSION: 0x01000000 +ALTAIR_FORK_EPOCH: 74240 # Oct 27, 2021, 10:56:23am UTC +# Bellatrix +BELLATRIX_FORK_VERSION: 0x02000000 +BELLATRIX_FORK_EPOCH: 144896 # Sept 6, 2022, 11:34:47am UTC +# Capella +CAPELLA_FORK_VERSION: 0x03000000 +CAPELLA_FORK_EPOCH: 194048 # April 12, 2023, 10:27:35pm UTC +# Deneb +DENEB_FORK_VERSION: 0x04000000 +DENEB_FORK_EPOCH: 269568 # March 13, 2024, 01:55:35pm UTC +# Electra +ELECTRA_FORK_VERSION: 0x05000000 +ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC +# Fulu +FULU_FORK_VERSION: 0x06000000 +FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC +# Gloas +GLOAS_FORK_VERSION: 0x07000000 +GLOAS_FORK_EPOCH: 18446744073709551615 +# Heze +HEZE_FORK_VERSION: 0x08000000 +HEZE_FORK_EPOCH: 18446744073709551615 +# EIP7928 +EIP7928_FORK_VERSION: 0xe7928000 # temporary stub +EIP7928_FORK_EPOCH: 18446744073709551615 + +# Time parameters +# --------------------------------------------------------------- +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + +# Gloas +# 2**6 (= 64) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + +# Heze +# 6667 basis points, ~67% of SLOT_DURATION_MS +INCLUSION_LIST_DUE_BPS: 6667 + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Gloas +# 2**15 (= 32,768) +CHURN_LIMIT_QUOTIENT_GLOAS: 32768 +# 2**16 (= 65,536) +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 65536 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum PoW Mainnet +DEPOSIT_CHAIN_ID: 1 +DEPOSIT_NETWORK_ID: 1 +DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + +# Heze +# 2**4 (= 16) inclusion lists +MAX_REQUEST_INCLUSION_LIST: 16 +# 2**13 (= 8,192) bytes +MAX_BYTES_PER_INCLUSION_LIST: 8192 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 412672 # December 9, 2025, 02:21:11pm UTC + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 419072 # January 7, 2026, 01:01:11am UTC + MAX_BLOBS_PER_BLOCK: 21 diff --git a/consensus/types/configs/minimal.yaml b/consensus/types/configs/minimal.yaml new file mode 100644 index 0000000000..7251efc762 --- /dev/null +++ b/consensus/types/configs/minimal.yaml @@ -0,0 +1,224 @@ +# Minimal config + +# Extends the minimal preset +PRESET_BASE: 'minimal' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'minimal' - spec-testing +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'minimal' + +# Transition +# --------------------------------------------------------------- +# 2**256-2**10 for testing minimal network +TERMINAL_TOTAL_DIFFICULTY: 115792089237316195423570985008687907853269984665640564039457584007913129638912 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + +# Genesis +# --------------------------------------------------------------- +# [customized] 2**6 (= 64) validators +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 64 +# [customized] Jan 3, 2020, 12am UTC +MIN_GENESIS_TIME: 1578009600 +# [customized] Initial fork version for minimal +GENESIS_FORK_VERSION: 0x00000001 +# [customized] 5 * 60 (= 300) seconds +GENESIS_DELAY: 300 + +# Forking +# --------------------------------------------------------------- +# Values provided for illustrative purposes. +# Individual tests/testnets may set different values. + +# [customized] Altair +ALTAIR_FORK_VERSION: 0x01000001 +ALTAIR_FORK_EPOCH: 18446744073709551615 +# [customized] Bellatrix +BELLATRIX_FORK_VERSION: 0x02000001 +BELLATRIX_FORK_EPOCH: 18446744073709551615 +# [customized] Capella +CAPELLA_FORK_VERSION: 0x03000001 +CAPELLA_FORK_EPOCH: 18446744073709551615 +# [customized] Deneb +DENEB_FORK_VERSION: 0x04000001 +DENEB_FORK_EPOCH: 18446744073709551615 +# [customized] Electra +ELECTRA_FORK_VERSION: 0x05000001 +ELECTRA_FORK_EPOCH: 18446744073709551615 +# [customized] Fulu +FULU_FORK_VERSION: 0x06000001 +FULU_FORK_EPOCH: 18446744073709551615 +# [customized] Gloas +GLOAS_FORK_VERSION: 0x07000001 +GLOAS_FORK_EPOCH: 18446744073709551615 +# [customized] Heze +HEZE_FORK_VERSION: 0x08000001 +HEZE_FORK_EPOCH: 18446744073709551615 +# [customized] EIP7928 +EIP7928_FORK_VERSION: 0xe7928001 +EIP7928_FORK_EPOCH: 18446744073709551615 + +# Time parameters +# --------------------------------------------------------------- +# [customized] 6000 milliseconds +SLOT_DURATION_MS: 6000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# [customized] 2**6 (= 64) epochs +SHARD_COMMITTEE_PERIOD: 64 +# [customized] 2**4 (= 16) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 16 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + +# Gloas +# [customized] 2**1 (= 2) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 2 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + +# Heze +# 6667 basis points, ~67% of SLOT_DURATION_MS +INCLUSION_LIST_DUE_BPS: 6667 + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# [customized] 2**1 (= 2) validators +MIN_PER_EPOCH_CHURN_LIMIT: 2 +# [customized] 2**5 (= 32) +CHURN_LIMIT_QUOTIENT: 32 + +# Deneb +# [customized] 2**2 (= 4) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 4 + +# Electra +# [customized] 2**6 * 10**9 (= 64,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 64000000000 +# [customized] 2**7 * 10**9 (= 128,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 128000000000 + +# Gloas +# [customized] 2**4 (= 16) +CHURN_LIMIT_QUOTIENT_GLOAS: 16 +# [customized] 2**5 (= 32) +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 32 +# [customized] 2**7 * 10**9 (= 128,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: 128000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum Goerli testnet +DEPOSIT_CHAIN_ID: 5 +DEPOSIT_NETWORK_ID: 5 +# Configured on a per testnet basis +DEPOSIT_CONTRACT_ADDRESS: 0x1234567890123456789012345678901234567890 + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + +# Heze +# 2**4 (= 16) inclusion lists +MAX_REQUEST_INCLUSION_LIST: 16 +# 2**13 (= 8,192) bytes +MAX_BYTES_PER_INCLUSION_LIST: 8192 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: [] diff --git a/consensus/types/presets/gnosis/gloas.yaml b/consensus/types/presets/gnosis/gloas.yaml index 170accaac3..d1a48adca1 100644 --- a/consensus/types/presets/gnosis/gloas.yaml +++ b/consensus/types/presets/gnosis/gloas.yaml @@ -1 +1,23 @@ # Gnosis preset - Gloas + +# Misc +# --------------------------------------------------------------- +# 2**9 (= 512) validators +PTC_SIZE: 512 + +# Max operations per block +# --------------------------------------------------------------- +# 2**1 (= 2) attestations +MAX_PAYLOAD_ATTESTATIONS: 2 + +# State list lengths +# --------------------------------------------------------------- +# 2**40 (= 1,099,511,627,776) builder spots +BUILDER_REGISTRY_LIMIT: 1099511627776 +# 2**20 (= 1,048,576) builder pending withdrawals +BUILDER_PENDING_WITHDRAWALS_LIMIT: 1048576 + +# Withdrawals processing +# --------------------------------------------------------------- +# 2**14 (= 16,384) builders +MAX_BUILDERS_PER_WITHDRAWALS_SWEEP: 16384 diff --git a/consensus/types/presets/mainnet/gloas.yaml b/consensus/types/presets/mainnet/gloas.yaml index 45b7b8ae96..2da198dcae 100644 --- a/consensus/types/presets/mainnet/gloas.yaml +++ b/consensus/types/presets/mainnet/gloas.yaml @@ -1 +1,23 @@ # Mainnet preset - Gloas + +# Misc +# --------------------------------------------------------------- +# 2**9 (= 512) validators +PTC_SIZE: 512 + +# Max operations per block +# --------------------------------------------------------------- +# 2**2 (= 4) attestations +MAX_PAYLOAD_ATTESTATIONS: 4 + +# State list lengths +# --------------------------------------------------------------- +# 2**40 (= 1,099,511,627,776) builder spots +BUILDER_REGISTRY_LIMIT: 1099511627776 +# 2**20 (= 1,048,576) builder pending withdrawals +BUILDER_PENDING_WITHDRAWALS_LIMIT: 1048576 + +# Withdrawals processing +# --------------------------------------------------------------- +# 2**14 (= 16,384) builders +MAX_BUILDERS_PER_WITHDRAWALS_SWEEP: 16384 \ No newline at end of file diff --git a/consensus/types/presets/minimal/gloas.yaml b/consensus/types/presets/minimal/gloas.yaml index 51b3f04857..559c2d46df 100644 --- a/consensus/types/presets/minimal/gloas.yaml +++ b/consensus/types/presets/minimal/gloas.yaml @@ -1 +1,23 @@ # Minimal preset - Gloas + +# Misc +# --------------------------------------------------------------- +# [customized] 2**4 (= 16) validators +PTC_SIZE: 16 + +# Max operations per block +# --------------------------------------------------------------- +# 2**2 (= 4) attestations +MAX_PAYLOAD_ATTESTATIONS: 4 + +# State list lengths +# --------------------------------------------------------------- +# 2**40 (= 1,099,511,627,776) builder spots +BUILDER_REGISTRY_LIMIT: 1099511627776 +# 2**20 (= 1,048,576) builder pending withdrawals +BUILDER_PENDING_WITHDRAWALS_LIMIT: 1048576 + +# Withdrawals processing +# --------------------------------------------------------------- +# [customized] 2**4 (= 16) builders +MAX_BUILDERS_PER_WITHDRAWALS_SWEEP: 16 diff --git a/consensus/types/src/attestation/attestation.rs b/consensus/types/src/attestation/attestation.rs index 693b5889f5..28059efee6 100644 --- a/consensus/types/src/attestation/attestation.rs +++ b/consensus/types/src/attestation/attestation.rs @@ -102,6 +102,7 @@ impl<E: EthSpec> Hash for Attestation<E> { impl<E: EthSpec> Attestation<E> { /// Produces an attestation with empty signature. + #[allow(clippy::too_many_arguments)] pub fn empty_for_signing( committee_index: u64, committee_length: usize, @@ -109,6 +110,7 @@ impl<E: EthSpec> Attestation<E> { beacon_block_root: Hash256, source: Checkpoint, target: Checkpoint, + payload_present: bool, spec: &ChainSpec, ) -> Result<Self, Error> { if spec.fork_name_at_slot::<E>(slot).electra_enabled() { @@ -116,12 +118,19 @@ impl<E: EthSpec> Attestation<E> { committee_bits .set(committee_index as usize, true) .map_err(|_| Error::InvalidCommitteeIndex)?; + // Gloas attestation data index now indicates payload presence. + // Pre-gloas index is always 0. + let index = if spec.fork_name_at_slot::<E>(slot).gloas_enabled() && payload_present { + 1u64 + } else { + 0u64 + }; Ok(Attestation::Electra(AttestationElectra { aggregation_bits: BitList::with_capacity(committee_length) .map_err(|_| Error::InvalidCommitteeLength)?, data: AttestationData { slot, - index: 0u64, + index, beacon_block_root, source, target, diff --git a/consensus/types/src/attestation/indexed_payload_attestation.rs b/consensus/types/src/attestation/indexed_payload_attestation.rs index 4de805570c..bb2087e330 100644 --- a/consensus/types/src/attestation/indexed_payload_attestation.rs +++ b/consensus/types/src/attestation/indexed_payload_attestation.rs @@ -2,7 +2,6 @@ use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName, PayloadAttestationData}; use bls::AggregateSignature; use context_deserialize::context_deserialize; -use core::slice::Iter; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; @@ -21,12 +20,6 @@ pub struct IndexedPayloadAttestation<E: EthSpec> { pub signature: AggregateSignature, } -impl<E: EthSpec> IndexedPayloadAttestation<E> { - pub fn attesting_indices_iter(&self) -> Iter<'_, u64> { - self.attesting_indices.iter() - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/consensus/types/src/attestation/mod.rs b/consensus/types/src/attestation/mod.rs index 586d99bd90..5b59b83e72 100644 --- a/consensus/types/src/attestation/mod.rs +++ b/consensus/types/src/attestation/mod.rs @@ -11,6 +11,7 @@ mod payload_attestation; mod payload_attestation_data; mod payload_attestation_message; mod pending_attestation; +mod ptc; mod selection_proof; mod shuffling_id; mod signed_aggregate_and_proof; @@ -36,6 +37,7 @@ pub use payload_attestation::PayloadAttestation; pub use payload_attestation_data::PayloadAttestationData; pub use payload_attestation_message::PayloadAttestationMessage; pub use pending_attestation::PendingAttestation; +pub use ptc::PTC; pub use selection_proof::SelectionProof; pub use shuffling_id::AttestationShufflingId; pub use signed_aggregate_and_proof::{ diff --git a/consensus/types/src/attestation/payload_attestation.rs b/consensus/types/src/attestation/payload_attestation.rs index 192a4a8fea..115a5ec4d6 100644 --- a/consensus/types/src/attestation/payload_attestation.rs +++ b/consensus/types/src/attestation/payload_attestation.rs @@ -5,7 +5,7 @@ use bls::AggregateSignature; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; -use ssz::BitList; +use ssz::BitVector; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -17,7 +17,7 @@ use tree_hash_derive::TreeHash; #[educe(PartialEq, Hash)] #[context_deserialize(ForkName)] pub struct PayloadAttestation<E: EthSpec> { - pub aggregation_bits: BitList<E::PTCSize>, + pub aggregation_bits: BitVector<E::PTCSize>, pub data: PayloadAttestationData, pub signature: AggregateSignature, } diff --git a/consensus/types/src/attestation/ptc.rs b/consensus/types/src/attestation/ptc.rs new file mode 100644 index 0000000000..1eef2f7d68 --- /dev/null +++ b/consensus/types/src/attestation/ptc.rs @@ -0,0 +1,23 @@ +use crate::EthSpec; +use ssz_types::FixedVector; + +#[derive(Clone, Debug, PartialEq)] +pub struct PTC<E: EthSpec>(pub FixedVector<usize, E::PTCSize>); + +impl<'a, E: EthSpec> IntoIterator for &'a PTC<E> { + type Item = &'a usize; + type IntoIter = std::slice::Iter<'a, usize>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl<E: EthSpec> IntoIterator for PTC<E> { + type Item = usize; + type IntoIter = std::vec::IntoIter<usize>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} diff --git a/consensus/types/src/block/beacon_block.rs b/consensus/types/src/block/beacon_block.rs index 96999188f3..5ca7ce065f 100644 --- a/consensus/types/src/block/beacon_block.rs +++ b/consensus/types/src/block/beacon_block.rs @@ -313,6 +313,27 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> BeaconBlockRef<'a, E, Payl pub fn execution_payload(&self) -> Result<Payload::Ref<'a>, BeaconStateError> { self.body().execution_payload() } + + pub fn blob_kzg_commitments_len(&self) -> Option<usize> { + match self { + BeaconBlockRef::Base(_) => None, + BeaconBlockRef::Altair(_) => None, + BeaconBlockRef::Bellatrix(_) => None, + BeaconBlockRef::Capella(_) => None, + BeaconBlockRef::Deneb(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Electra(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Fulu(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Eip7805(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Gloas(block) => Some( + block + .body + .signed_execution_payload_bid + .message + .blob_kzg_commitments + .len(), + ), + } + } } impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> BeaconBlockRefMut<'a, E, Payload> { @@ -731,6 +752,7 @@ impl<E: EthSpec, Payload: AbstractExecPayload<E>> EmptyBlock for BeaconBlockGloa voluntary_exits: VariableList::empty(), sync_aggregate: SyncAggregate::empty(), bls_to_execution_changes: VariableList::empty(), + parent_execution_requests: ExecutionRequests::default(), signed_execution_payload_bid: SignedExecutionPayloadBid::empty(), payload_attestations: VariableList::empty(), _phantom: PhantomData, diff --git a/consensus/types/src/block/beacon_block_body.rs b/consensus/types/src/block/beacon_block_body.rs index 2bd5574765..a40c8bdc5a 100644 --- a/consensus/types/src/block/beacon_block_body.rs +++ b/consensus/types/src/block/beacon_block_body.rs @@ -3,20 +3,22 @@ use std::marker::PhantomData; use bls::Signature; use context_deserialize::{ContextDeserialize, context_deserialize}; use educe::Educe; -use merkle_proof::{MerkleTree, MerkleTreeError}; +use merkle_proof::MerkleTree; use metastruct::metastruct; use serde::{Deserialize, Deserializer, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, VariableList}; use superstruct::superstruct; use test_random_derive::TestRandom; -use tree_hash::{BYTES_PER_CHUNK, TreeHash}; +use tree_hash::TreeHash; use tree_hash_derive::TreeHash; -use crate::payload_attestation::PayloadAttestation; use crate::{ SignedExecutionPayloadBid, - attestation::{AttestationBase, AttestationElectra, AttestationRef, AttestationRefMut}, + attestation::{ + AttestationBase, AttestationElectra, AttestationRef, AttestationRefMut, PayloadAttestation, + }, + complete_kzg_commitment_merkle_proof, core::{EthSpec, Graffiti, Hash256}, deposit::Deposit, execution::{ @@ -171,9 +173,11 @@ pub struct BeaconBlockBody<E: EthSpec, Payload: AbstractExecPayload<E> = FullPay #[superstruct(only(Electra, Eip7805, Fulu))] pub execution_requests: ExecutionRequests<E>, #[superstruct(only(Gloas))] - pub signed_execution_payload_bid: SignedExecutionPayloadBid, + pub signed_execution_payload_bid: SignedExecutionPayloadBid<E>, #[superstruct(only(Gloas))] pub payload_attestations: VariableList<PayloadAttestation<E>, E::MaxPayloadAttestations>, + #[superstruct(only(Gloas))] + pub parent_execution_requests: ExecutionRequests<E>, #[superstruct(only(Base, Altair, Gloas))] #[metastruct(exclude_from(fields))] #[ssz(skip_serializing, skip_deserializing)] @@ -279,46 +283,11 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> BeaconBlockBodyRef<'a, E, | Self::Capella(_) | Self::Gloas(_) => Err(BeaconStateError::IncorrectStateVariant), Self::Deneb(_) | Self::Electra(_) | Self::Fulu(_) | Self::Eip7805(_) => { - // We compute the branches by generating 2 merkle trees: - // 1. Merkle tree for the `blob_kzg_commitments` List object - // 2. Merkle tree for the `BeaconBlockBody` container - // We then merge the branches for both the trees all the way up to the root. - - // Part1 (Branches for the subtree rooted at `blob_kzg_commitments`) - // - // Branches for `blob_kzg_commitments` without length mix-in - let blob_leaves = self - .blob_kzg_commitments()? - .iter() - .map(|commitment| commitment.tree_hash_root()) - .collect::<Vec<_>>(); - let depth = E::max_blob_commitments_per_block() - .next_power_of_two() - .ilog2(); - let tree = MerkleTree::create(&blob_leaves, depth as usize); - let (_, mut proof) = tree - .generate_proof(index, depth as usize) - .map_err(BeaconStateError::MerkleTreeError)?; - - // Add the branch corresponding to the length mix-in. - let length = blob_leaves.len(); - let usize_len = std::mem::size_of::<usize>(); - let mut length_bytes = [0; BYTES_PER_CHUNK]; - length_bytes - .get_mut(0..usize_len) - .ok_or(BeaconStateError::MerkleTreeError( - MerkleTreeError::PleaseNotifyTheDevs, - ))? - .copy_from_slice(&length.to_le_bytes()); - let length_root = Hash256::from_slice(length_bytes.as_slice()); - proof.push(length_root); - - // Part 2 - // Branches for `BeaconBlockBody` container - // Join the proofs for the subtree and the main tree - proof.extend_from_slice(kzg_commitments_proof); - - Ok(FixedVector::new(proof)?) + complete_kzg_commitment_merkle_proof::<E>( + self.blob_kzg_commitments()?, + index, + kzg_commitments_proof, + ) } } } @@ -583,6 +552,7 @@ impl<E: EthSpec> From<BeaconBlockBodyGloas<E, BlindedPayload<E>>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom, @@ -599,6 +569,7 @@ impl<E: EthSpec> From<BeaconBlockBodyGloas<E, BlindedPayload<E>>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom: PhantomData, @@ -963,6 +934,7 @@ impl<E: EthSpec> From<BeaconBlockBodyGloas<E, FullPayload<E>>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom, @@ -980,6 +952,7 @@ impl<E: EthSpec> From<BeaconBlockBodyGloas<E, FullPayload<E>>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom: PhantomData, diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index 6444ecd415..8660094bae 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -14,6 +14,7 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ + ExecutionBlockHash, block::{ BLOB_KZG_COMMITMENTS_INDEX, BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BeaconBlockBellatrix, BeaconBlockBodyBellatrix, BeaconBlockBodyCapella, @@ -372,6 +373,42 @@ impl<E: EthSpec, Payload: AbstractExecPayload<E>> SignedBeaconBlock<E, Payload> format_kzg_commitments(commitments.as_ref()) } + + /// Convenience accessor for the block's bid's `block_hash`. + /// + /// This method returns an error prior to Gloas. + pub fn payload_bid_block_hash(&self) -> Result<ExecutionBlockHash, BeaconStateError> { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.block_hash) + } + + /// Convenience accessor for the block's bid's `parent_block_hash`. + /// + /// This method returns an error prior to Gloas. + pub fn payload_bid_parent_block_hash(&self) -> Result<ExecutionBlockHash, BeaconStateError> { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.parent_block_hash) + } + + /// Check if the `parent_hash` in this block's `signed_payload_bid` matches `parent_block_hash`. + /// + /// This function is useful post-Gloas for determining if the parent block is full, *without* + /// necessarily needing access to a beacon state. The passed in `parent_block_hash` MUST be the + /// `block_hash` from the parent beacon block's bid. If the parent beacon state is available + /// this can alternatively be fetched from `state.latest_payload_bid`. + /// + /// This function returns `false` for all blocks prior to Gloas. + pub fn is_parent_block_full(&self, parent_block_hash: ExecutionBlockHash) -> bool { + let Ok(signed_payload_bid) = self.message().body().signed_execution_payload_bid() else { + // Prior to Gloas. + return false; + }; + signed_payload_bid.message.parent_block_hash == parent_block_hash + } } // We can convert pre-Bellatrix blocks without payloads into blocks with payloads. diff --git a/consensus/types/src/builder/builder.rs b/consensus/types/src/builder/builder.rs new file mode 100644 index 0000000000..2bd50f42cc --- /dev/null +++ b/consensus/types/src/builder/builder.rs @@ -0,0 +1,26 @@ +use crate::test_utils::TestRandom; +use crate::{Address, Epoch, ForkName}; +use bls::PublicKeyBytes; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use test_random_derive::TestRandom; +use tree_hash_derive::TreeHash; + +pub type BuilderIndex = u64; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TestRandom, TreeHash, +)] +#[context_deserialize(ForkName)] +pub struct Builder { + pub pubkey: PublicKeyBytes, + #[serde(with = "serde_utils::quoted_u8")] + pub version: u8, + pub execution_address: Address, + #[serde(with = "serde_utils::quoted_u64")] + pub balance: u64, + pub deposit_epoch: Epoch, + pub withdrawable_epoch: Epoch, +} diff --git a/consensus/types/src/builder/builder_bid.rs b/consensus/types/src/builder/builder_bid.rs index ceb700e5d3..393a89f526 100644 --- a/consensus/types/src/builder/builder_bid.rs +++ b/consensus/types/src/builder/builder_bid.rs @@ -203,7 +203,7 @@ impl<E: EthSpec> SignedBuilderBid<E> { .pubkey() .decompress() .map(|pubkey| { - let domain = spec.get_builder_domain(); + let domain = spec.get_builder_application_domain(); let message = self.message.signing_root(domain); self.signature.verify(&pubkey, message) }) diff --git a/consensus/types/src/builder/builder_pending_withdrawal.rs b/consensus/types/src/builder/builder_pending_withdrawal.rs index 436d331c00..dbbb029a5d 100644 --- a/consensus/types/src/builder/builder_pending_withdrawal.rs +++ b/consensus/types/src/builder/builder_pending_withdrawal.rs @@ -1,5 +1,5 @@ use crate::test_utils::TestRandom; -use crate::{Address, Epoch, ForkName}; +use crate::{Address, ForkName}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; @@ -29,7 +29,6 @@ pub struct BuilderPendingWithdrawal { pub amount: u64, #[serde(with = "serde_utils::quoted_u64")] pub builder_index: u64, - pub withdrawable_epoch: Epoch, } #[cfg(test)] diff --git a/consensus/types/src/builder/mod.rs b/consensus/types/src/builder/mod.rs index ef17566cae..2ec489a3ca 100644 --- a/consensus/types/src/builder/mod.rs +++ b/consensus/types/src/builder/mod.rs @@ -1,10 +1,14 @@ +mod builder; mod builder_bid; mod builder_pending_payment; mod builder_pending_withdrawal; +mod proposer_preferences; +pub use builder::{Builder, BuilderIndex}; pub use builder_bid::{ BuilderBid, BuilderBidBellatrix, BuilderBidCapella, BuilderBidDeneb, BuilderBidEip7805, BuilderBidElectra, BuilderBidFulu, SignedBuilderBid, }; pub use builder_pending_payment::BuilderPendingPayment; pub use builder_pending_withdrawal::BuilderPendingWithdrawal; +pub use proposer_preferences::{ProposerPreferences, SignedProposerPreferences}; diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs new file mode 100644 index 0000000000..0d2ba760d4 --- /dev/null +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -0,0 +1,52 @@ +use crate::test_utils::TestRandom; +use crate::{Address, ForkName, Hash256, SignedRoot, Slot}; +use bls::Signature; +use context_deserialize::context_deserialize; +use educe::Educe; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use test_random_derive::TestRandom; +use tree_hash_derive::TreeHash; + +#[derive( + Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe, TestRandom, +)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[educe(PartialEq, Hash)] +#[context_deserialize(ForkName)] +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#new-proposerpreferences +pub struct ProposerPreferences { + pub checkpoint_root: Hash256, + pub proposal_slot: Slot, + pub validator_index: u64, + pub fee_recipient: Address, + pub gas_limit: u64, +} + +impl SignedRoot for ProposerPreferences {} + +#[derive(TestRandom, TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[educe(PartialEq, Hash)] +#[context_deserialize(ForkName)] +// https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#new-signedproposerpreferences +pub struct SignedProposerPreferences { + pub message: ProposerPreferences, + pub signature: Signature, +} + +impl SignedProposerPreferences { + pub fn empty() -> Self { + Self { + message: ProposerPreferences::default(), + signature: Signature::empty(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(ProposerPreferences); +} diff --git a/consensus/types/src/core/application_domain.rs b/consensus/types/src/core/application_domain.rs index 5e33f2dfd5..ff55a91034 100644 --- a/consensus/types/src/core/application_domain.rs +++ b/consensus/types/src/core/application_domain.rs @@ -4,6 +4,7 @@ pub const APPLICATION_DOMAIN_BUILDER: u32 = 16777216; #[derive(Debug, PartialEq, Clone, Copy)] pub enum ApplicationDomain { + /// NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. Builder, } diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index e423fd46fd..d5262acbdb 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -8,18 +8,16 @@ use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_utils::quoted_u64::MaybeQuoted; use ssz::Encode; -use ssz_types::{RuntimeVariableList, VariableList}; +use ssz_types::RuntimeVariableList; use tree_hash::TreeHash; use crate::{ + consts::bellatrix::BASIS_POINTS, core::{ APPLICATION_DOMAIN_BUILDER, Address, ApplicationDomain, EnrForkId, Epoch, EthSpec, - EthSpecId, Hash256, MainnetEthSpec, Slot, Uint256, + EthSpecId, ExecutionBlockHash, Hash256, MainnetEthSpec, Slot, Uint256, }, - data::{BlobIdentifier, DataColumnSubnetId, DataColumnsByRootIdentifier}, - execution::ExecutionBlockHash, fork::{Fork, ForkData, ForkName}, - state::BeaconState, }; /// Each of the BLS signature domains. @@ -38,6 +36,7 @@ pub enum Domain { SyncCommitteeSelectionProof, BeaconBuilder, PTCAttester, + ProposerPreferences, ApplicationMask(ApplicationDomain), InclusionListCommittee, } @@ -98,8 +97,9 @@ pub struct ChainSpec { * Time parameters */ pub genesis_delay: u64, - pub seconds_per_slot: u64, - pub slot_duration_ms: u64, + seconds_per_slot: u64, + // Private so that this value can't get changed except via the `set_slot_duration_ms` function. + slot_duration_ms: u64, pub min_attestation_inclusion_delay: u64, pub min_seed_lookahead: Epoch, pub max_seed_lookahead: Epoch, @@ -108,10 +108,22 @@ pub struct ChainSpec { pub shard_committee_period: u64, pub proposer_reorg_cutoff_bps: u64, pub attestation_due_bps: u64, + pub attestation_due_bps_gloas: u64, + pub payload_attestation_due_bps: u64, pub aggregate_due_bps: u64, pub sync_message_due_bps: u64, pub contribution_due_bps: u64, + /* + * Derived time values (computed at startup via `compute_derived_values()`) + */ + pub unaggregated_attestation_due: Duration, + pub unaggregated_attestation_due_gloas: Duration, + pub payload_attestation_due: Duration, + pub aggregate_attestation_due: Duration, + pub sync_message_due: Duration, + pub contribution_and_proof_due: Duration, + /* * Reward and penalty quotients */ @@ -133,6 +145,7 @@ pub struct ChainSpec { pub(crate) domain_aggregate_and_proof: u32, pub(crate) domain_beacon_builder: u32, pub(crate) domain_ptc_attester: u32, + pub(crate) domain_proposer_preferences: u32, /* * Fork choice @@ -140,6 +153,7 @@ pub struct ChainSpec { pub proposer_score_boost: Option<u64>, pub reorg_head_weight_threshold: Option<u64>, pub reorg_parent_weight_threshold: Option<u64>, + pub reorg_max_epochs_since_finalization: Option<u64>, /* * Eth1 @@ -165,6 +179,7 @@ pub struct ChainSpec { pub inactivity_score_bias: u64, pub inactivity_score_recovery_rate: u64, pub min_sync_committee_participants: u64, + pub update_timeout: u64, pub(crate) domain_sync_committee: u32, pub(crate) domain_sync_committee_selection_proof: u32, pub(crate) domain_contribution_and_proof: u32, @@ -244,6 +259,10 @@ pub struct ChainSpec { pub gloas_fork_epoch: Option<Epoch>, pub builder_payment_threshold_numerator: u64, pub builder_payment_threshold_denominator: u64, + pub min_builder_withdrawability_delay: Epoch, + pub churn_limit_quotient_gloas: u64, + pub consolidation_churn_limit_quotient: u64, + pub max_per_epoch_activation_churn_limit_gloas: u64, /* * Networking @@ -261,7 +280,9 @@ pub struct ChainSpec { pub message_domain_invalid_snappy: [u8; 4], pub message_domain_valid_snappy: [u8; 4], pub subnets_per_node: u8, + pub epochs_per_subnet_subscription: u64, pub attestation_subnet_count: u64, + pub attestation_subnet_extra_bits: u8, pub attestation_subnet_prefix_bits: u8, /* @@ -290,6 +311,7 @@ pub struct ChainSpec { /* * Networking Gloas */ + pub max_request_payloads: u64, /* * Networking Derived @@ -300,6 +322,7 @@ pub struct ChainSpec { pub max_blocks_by_root_request_deneb: usize, pub max_blobs_by_root_request: usize, pub max_data_columns_by_root_request: usize, + pub max_payload_envelopes_by_root_request: usize, /* * Application params @@ -430,51 +453,6 @@ impl ChainSpec { } } - /// For a given `BeaconState`, return the proportional slashing multiplier associated with its variant. - pub fn proportional_slashing_multiplier_for_state<E: EthSpec>( - &self, - state: &BeaconState<E>, - ) -> u64 { - let fork_name = state.fork_name_unchecked(); - if fork_name >= ForkName::Bellatrix { - self.proportional_slashing_multiplier_bellatrix - } else if fork_name >= ForkName::Altair { - self.proportional_slashing_multiplier_altair - } else { - self.proportional_slashing_multiplier - } - } - - /// For a given `BeaconState`, return the minimum slashing penalty quotient associated with its variant. - pub fn min_slashing_penalty_quotient_for_state<E: EthSpec>( - &self, - state: &BeaconState<E>, - ) -> u64 { - let fork_name = state.fork_name_unchecked(); - if fork_name.electra_enabled() { - self.min_slashing_penalty_quotient_electra - } else if fork_name >= ForkName::Bellatrix { - self.min_slashing_penalty_quotient_bellatrix - } else if fork_name >= ForkName::Altair { - self.min_slashing_penalty_quotient_altair - } else { - self.min_slashing_penalty_quotient - } - } - - /// For a given `BeaconState`, return the whistleblower reward quotient associated with its variant. - pub fn whistleblower_reward_quotient_for_state<E: EthSpec>( - &self, - state: &BeaconState<E>, - ) -> u64 { - let fork_name = state.fork_name_unchecked(); - if fork_name.electra_enabled() { - self.whistleblower_reward_quotient_electra - } else { - self.whistleblower_reward_quotient - } - } - pub fn max_effective_balance_for_fork(&self, fork_name: ForkName) -> u64 { if fork_name.electra_enabled() { self.max_effective_balance_electra @@ -568,6 +546,7 @@ impl ChainSpec { Domain::AggregateAndProof => self.domain_aggregate_and_proof, Domain::BeaconBuilder => self.domain_beacon_builder, Domain::PTCAttester => self.domain_ptc_attester, + Domain::ProposerPreferences => self.domain_proposer_preferences, Domain::SyncCommittee => self.domain_sync_committee, Domain::ContributionAndProof => self.domain_contribution_and_proof, Domain::SyncCommitteeSelectionProof => self.domain_sync_committee_selection_proof, @@ -604,7 +583,9 @@ impl ChainSpec { // This should be updated to include the current fork and the genesis validators root, but discussion is ongoing: // // https://github.com/ethereum/builder-specs/issues/14 - pub fn get_builder_domain(&self) -> Hash256 { + // + // NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. + pub fn get_builder_application_domain(&self) -> Hash256 { self.compute_domain( Domain::ApplicationMask(ApplicationDomain::Builder), self.genesis_fork_version, @@ -753,6 +734,10 @@ impl ChainSpec { } } + pub fn max_request_payloads(&self) -> usize { + self.max_request_payloads as usize + } + pub fn max_request_blob_sidecars(&self, fork_name: ForkName) -> usize { if fork_name.electra_enabled() { self.max_request_blob_sidecars_electra as usize @@ -876,22 +861,20 @@ impl ChainSpec { /// Returns the min epoch for blob / data column sidecar requests based on the current epoch. /// Switch to use the column sidecar config once the `blob_retention_epoch` has passed Fulu fork epoch. + /// Never uses the `blob_retention_epoch` for networks that started with Fulu enabled. pub fn min_epoch_data_availability_boundary(&self, current_epoch: Epoch) -> Option<Epoch> { - let fork_epoch = self.deneb_fork_epoch?; + let deneb_fork_epoch = self.deneb_fork_epoch?; let blob_retention_epoch = current_epoch.saturating_sub(self.min_epochs_for_blob_sidecars_requests); - match self.fulu_fork_epoch { - Some(fulu_fork_epoch) if blob_retention_epoch > fulu_fork_epoch => Some( - current_epoch.saturating_sub(self.min_epochs_for_data_column_sidecars_requests), - ), - _ => Some(std::cmp::max(fork_epoch, blob_retention_epoch)), + if let Some(fulu_fork_epoch) = self.fulu_fork_epoch + && blob_retention_epoch >= fulu_fork_epoch + { + Some(current_epoch.saturating_sub(self.min_epochs_for_data_column_sidecars_requests)) + } else { + Some(std::cmp::max(deneb_fork_epoch, blob_retention_epoch)) } } - pub fn all_data_column_sidecar_subnets(&self) -> impl Iterator<Item = DataColumnSubnetId> { - (0..self.data_column_sidecar_subnet_count).map(DataColumnSubnetId::new) - } - /// Worst-case compressed length for a given payload of size n when using snappy. /// /// https://github.com/google/snappy/blob/32ded457c0b1fe78ceb8397632c416568d6714a0/snappy.cc#L218C1-L218C47 @@ -921,6 +904,133 @@ impl ChainSpec { ) } + /// Get the duration into a slot in which an unaggregated attestation is due. + /// Returns the pre-computed value from `compute_derived_values()`. + pub fn get_unaggregated_attestation_due(&self) -> Duration { + self.unaggregated_attestation_due + } + + /// Spec: `get_attestation_due_ms`. Returns the epoch-appropriate threshold. + pub fn get_attestation_due<E: EthSpec>(&self, slot: Slot) -> Duration { + if self.fork_name_at_slot::<E>(slot).gloas_enabled() { + self.unaggregated_attestation_due_gloas + } else { + self.unaggregated_attestation_due + } + } + + /// Spec: `get_payload_attestation_due_ms`. + pub fn get_payload_attestation_due(&self) -> Duration { + self.payload_attestation_due + } + + /// Get the duration into a slot in which an aggregated attestation is due. + /// Returns the pre-computed value from `compute_derived_values()`. + pub fn get_aggregate_attestation_due(&self) -> Duration { + self.aggregate_attestation_due + } + + /// Get the duration into a slot in which a `SignedContributionAndProof` is due. + /// Returns the pre-computed value from `compute_derived_values()`. + pub fn get_contribution_message_due(&self) -> Duration { + self.contribution_and_proof_due + } + + /// Get the duration into a slot in which a sync committee message is due. + /// Returns the pre-computed value from `compute_derived_values()`. + pub fn get_sync_message_due(&self) -> Duration { + self.sync_message_due + } + + /// Calculate the duration into a slot for a given slot component + fn compute_slot_component_duration( + &self, + component_basis_points: u64, + ) -> Result<Duration, ArithError> { + Ok(Duration::from_millis( + component_basis_points + .safe_mul(self.slot_duration_ms)? + .safe_div(BASIS_POINTS)?, + )) + } + + /// Get the duration of a slot + pub fn get_slot_duration(&self) -> Duration { + Duration::from_millis(self.slot_duration_ms) + } + + /// Set the duration of a slot (in ms). + pub fn set_slot_duration_ms<E: EthSpec>(mut self, slot_duration_ms: u64) -> Self { + self.slot_duration_ms = slot_duration_ms; + self.seconds_per_slot = slot_duration_ms.saturating_div(1000); + self.compute_derived_values::<E>() + } + + /// Compute values that are derived from other config values. + /// + /// Must be called after loading or modifying a ChainSpec's fields. + /// + /// Panics if any computation fails (indicates invalid config). + pub fn compute_derived_values<E: EthSpec>(mut self) -> Self { + assert!( + self.attestation_due_bps <= BASIS_POINTS, + "invalid chain spec: attestation_due_bps ({}) exceeds slot duration", + self.attestation_due_bps + ); + assert!( + self.aggregate_due_bps <= BASIS_POINTS, + "invalid chain spec: aggregate_due_bps ({}) exceeds slot duration", + self.aggregate_due_bps + ); + assert!( + self.sync_message_due_bps <= BASIS_POINTS, + "invalid chain spec: sync_message_due_bps ({}) exceeds slot duration", + self.sync_message_due_bps + ); + assert!( + self.contribution_due_bps <= BASIS_POINTS, + "invalid chain spec: contribution_due_bps ({}) exceeds slot duration", + self.contribution_due_bps + ); + + self.unaggregated_attestation_due = self + .compute_slot_component_duration(self.attestation_due_bps) + .expect("invalid chain spec: cannot compute unaggregated_attestation_due"); + self.unaggregated_attestation_due_gloas = self + .compute_slot_component_duration(self.attestation_due_bps_gloas) + .expect("invalid chain spec: cannot compute unaggregated_attestation_due_gloas"); + self.payload_attestation_due = self + .compute_slot_component_duration(self.payload_attestation_due_bps) + .expect("invalid chain spec: cannot compute payload_attestation_due"); + self.aggregate_attestation_due = self + .compute_slot_component_duration(self.aggregate_due_bps) + .expect("invalid chain spec: cannot compute aggregate_attestation_due"); + self.sync_message_due = self + .compute_slot_component_duration(self.sync_message_due_bps) + .expect("invalid chain spec: cannot compute sync_message_due"); + self.contribution_and_proof_due = self + .compute_slot_component_duration(self.contribution_due_bps) + .expect("invalid chain spec: cannot compute contribution_and_proof_due"); + + self.attestation_subnet_prefix_bits = compute_attestation_subnet_prefix_bits( + self.attestation_subnet_count, + self.attestation_subnet_extra_bits, + ); + + self.max_blocks_by_root_request = + max_blocks_by_root_request_common(self.max_request_blocks); + self.max_blocks_by_root_request_deneb = + max_blocks_by_root_request_common(self.max_request_blocks_deneb); + self.max_blobs_by_root_request = + max_blobs_by_root_request_common(self.max_request_blob_sidecars); + self.max_data_columns_by_root_request = + max_data_columns_by_root_request_common::<E>(self.max_request_blocks_deneb); + self.max_payload_envelopes_by_root_request = + max_blocks_by_root_request_common(self.max_request_payloads); + + self + } + /// Returns the slot at which the proposer shuffling was decided. /// /// The block root at this slot can be used to key the proposer shuffling for the given epoch. @@ -1022,10 +1132,22 @@ impl ChainSpec { shard_committee_period: 256, proposer_reorg_cutoff_bps: 1667, attestation_due_bps: 3333, + attestation_due_bps_gloas: 2500, + payload_attestation_due_bps: 7500, aggregate_due_bps: 6667, sync_message_due_bps: 3333, contribution_due_bps: 6667, + /* + * Derived time values (set by `compute_derived_values()`) + */ + unaggregated_attestation_due: Duration::from_millis(3999), + unaggregated_attestation_due_gloas: Duration::from_millis(3000), + payload_attestation_due: Duration::from_millis(9000), + aggregate_attestation_due: Duration::from_millis(8000), + sync_message_due: Duration::from_millis(3999), + contribution_and_proof_due: Duration::from_millis(8000), + /* * Reward and penalty quotients */ @@ -1046,8 +1168,9 @@ impl ChainSpec { domain_voluntary_exit: 4, domain_selection_proof: 5, domain_aggregate_and_proof: 6, - domain_beacon_builder: 0x1B, + domain_beacon_builder: 0x0B, domain_ptc_attester: 0x0C, + domain_proposer_preferences: 0x0D, /* * Fork choice @@ -1055,6 +1178,7 @@ impl ChainSpec { proposer_score_boost: Some(40), reorg_head_weight_threshold: Some(20), reorg_parent_weight_threshold: Some(160), + reorg_max_epochs_since_finalization: Some(2), /* * Eth1 @@ -1085,6 +1209,7 @@ impl ChainSpec { inactivity_score_bias: 4, inactivity_score_recovery_rate: 16, min_sync_committee_participants: 1, + update_timeout: 8192, epochs_per_sync_committee_period: Epoch::new(256), domain_sync_committee: 7, domain_sync_committee_selection_proof: 8, @@ -1178,6 +1303,16 @@ impl ChainSpec { gloas_fork_epoch: None, builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, + min_builder_withdrawability_delay: Epoch::new(64), + churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 15)) + .expect("calculation does not overflow"), + consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 16)) + .expect("calculation does not overflow"), + max_per_epoch_activation_churn_limit_gloas: option_wrapper(|| { + u64::checked_pow(2, 8)?.checked_mul(u64::checked_pow(10, 9)?) + }) + .expect("calculation does not overflow"), + max_request_payloads: 128, /* * Network specific @@ -1185,7 +1320,9 @@ impl ChainSpec { boot_nodes: vec![], network_id: 1, // mainnet network id attestation_propagation_slot_range: default_attestation_propagation_slot_range(), + epochs_per_subnet_subscription: 256, attestation_subnet_count: 64, + attestation_subnet_extra_bits: 0, subnets_per_node: 2, maximum_gossip_clock_disparity: default_maximum_gossip_clock_disparity(), target_aggregators_per_committee: 16, @@ -1195,7 +1332,10 @@ impl ChainSpec { resp_timeout: default_resp_timeout(), message_domain_invalid_snappy: default_message_domain_invalid_snappy(), message_domain_valid_snappy: default_message_domain_valid_snappy(), - attestation_subnet_prefix_bits: default_attestation_subnet_prefix_bits(), + attestation_subnet_prefix_bits: compute_attestation_subnet_prefix_bits( + default_attestation_subnet_count(), + default_attestation_subnet_extra_bits(), + ), max_request_blocks: default_max_request_blocks(), /* @@ -1238,6 +1378,7 @@ impl ChainSpec { min_epochs_for_data_column_sidecars_requests: default_min_epochs_for_data_column_sidecars_requests(), max_data_columns_by_root_request: default_data_columns_by_root_request(), + max_payload_envelopes_by_root_request: default_max_payload_envelopes_by_root_request(), /* * Application specific @@ -1271,11 +1412,13 @@ impl ChainSpec { shard_committee_period: 64, genesis_delay: 300, seconds_per_slot: 6, + slot_duration_ms: 6000, inactivity_penalty_quotient: u64::checked_pow(2, 25).expect("pow does not overflow"), min_slashing_penalty_quotient: 64, proportional_slashing_multiplier: 2, // Altair epochs_per_sync_committee_period: Epoch::new(8), + update_timeout: 64, altair_fork_version: [0x01, 0x00, 0x00, 0x01], altair_fork_epoch: None, // Bellatrix @@ -1315,8 +1458,32 @@ impl ChainSpec { fulu_fork_version: [0x07, 0x00, 0x00, 0x00], fulu_fork_epoch: None, // Gloas - gloas_fork_version: [0x07, 0x00, 0x00, 0x00], + gloas_fork_version: [0x07, 0x00, 0x00, 0x01], gloas_fork_epoch: None, + min_builder_withdrawability_delay: Epoch::new(2), + churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 4)) + .expect("calculation does not overflow"), + consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 5)) + .expect("calculation does not overflow"), + max_per_epoch_activation_churn_limit_gloas: option_wrapper(|| { + u64::checked_pow(2, 7)?.checked_mul(u64::checked_pow(10, 9)?) + }) + .expect("calculation does not overflow"), + + /* + * Derived time values (set by `compute_derived_values()`) + * Precomputed for 6000ms slot: 3333 bps = 1999ms, 6667 bps = 4000ms + */ + unaggregated_attestation_due: Duration::from_millis(1999), + unaggregated_attestation_due_gloas: Duration::from_millis(1500), + payload_attestation_due: Duration::from_millis(4500), + aggregate_attestation_due: Duration::from_millis(4000), + sync_message_due: Duration::from_millis(1999), + contribution_and_proof_due: Duration::from_millis(4000), + + // Networking Fulu + blob_schedule: BlobSchedule::default(), + // Other network_id: 2, // lighthouse testnet network id deposit_chain_id: 5, @@ -1399,9 +1566,20 @@ impl ChainSpec { shard_committee_period: 256, proposer_reorg_cutoff_bps: 1667, attestation_due_bps: 3333, + attestation_due_bps_gloas: 2500, + payload_attestation_due_bps: 7500, aggregate_due_bps: 6667, - sync_message_due_bps: 3333, - contribution_due_bps: 6667, + + /* + * Derived time values (set by `compute_derived_values()`) + * Precomputed for 5000ms slot: 3333 bps = 1666ms, 6667 bps = 3333ms + */ + unaggregated_attestation_due: Duration::from_millis(1666), + unaggregated_attestation_due_gloas: Duration::from_millis(1250), + payload_attestation_due: Duration::from_millis(3750), + aggregate_attestation_due: Duration::from_millis(3333), + sync_message_due: Duration::from_millis(1666), + contribution_and_proof_due: Duration::from_millis(3333), /* * Reward and penalty quotients @@ -1423,8 +1601,9 @@ impl ChainSpec { domain_voluntary_exit: 4, domain_selection_proof: 5, domain_aggregate_and_proof: 6, - domain_beacon_builder: 0x1B, + domain_beacon_builder: 0x0B, domain_ptc_attester: 0x0C, + domain_proposer_preferences: 0x0D, /* * Fork choice @@ -1432,6 +1611,7 @@ impl ChainSpec { proposer_score_boost: Some(40), reorg_head_weight_threshold: Some(20), reorg_parent_weight_threshold: Some(160), + reorg_max_epochs_since_finalization: Some(2), /* * Eth1 @@ -1462,12 +1642,15 @@ impl ChainSpec { inactivity_score_bias: 4, inactivity_score_recovery_rate: 16, min_sync_committee_participants: 1, + update_timeout: 8192, epochs_per_sync_committee_period: Epoch::new(512), domain_sync_committee: 7, domain_sync_committee_selection_proof: 8, domain_contribution_and_proof: 9, altair_fork_version: [0x01, 0x00, 0x00, 0x64], altair_fork_epoch: Some(Epoch::new(512)), + sync_message_due_bps: 3333, + contribution_due_bps: 6667, /* * Bellatrix hard fork params @@ -1538,8 +1721,8 @@ impl ChainSpec { /* * Fulu hard fork params */ - fulu_fork_version: [0x07, 0x00, 0x00, 0x00], - fulu_fork_epoch: None, + fulu_fork_version: [0x06, 0x00, 0x00, 0x64], + fulu_fork_epoch: Some(Epoch::new(1714688)), custody_requirement: 4, number_of_custody_groups: 128, data_column_sidecar_subnet_count: 128, @@ -1554,6 +1737,16 @@ impl ChainSpec { gloas_fork_epoch: None, builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, + min_builder_withdrawability_delay: Epoch::new(64), + churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 15)) + .expect("calculation does not overflow"), + consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 16)) + .expect("calculation does not overflow"), + max_per_epoch_activation_churn_limit_gloas: option_wrapper(|| { + u64::checked_pow(2, 8)?.checked_mul(u64::checked_pow(10, 9)?) + }) + .expect("calculation does not overflow"), + max_request_payloads: 128, /* * Network specific @@ -1561,7 +1754,9 @@ impl ChainSpec { boot_nodes: vec![], network_id: 100, // Gnosis Chain network id attestation_propagation_slot_range: default_attestation_propagation_slot_range(), + epochs_per_subnet_subscription: 256, attestation_subnet_count: 64, + attestation_subnet_extra_bits: 0, subnets_per_node: 4, // Make this larger than usual to avoid network damage maximum_gossip_clock_disparity: default_maximum_gossip_clock_disparity(), target_aggregators_per_committee: 16, @@ -1572,7 +1767,10 @@ impl ChainSpec { message_domain_invalid_snappy: default_message_domain_invalid_snappy(), message_domain_valid_snappy: default_message_domain_valid_snappy(), max_request_blocks: default_max_request_blocks(), - attestation_subnet_prefix_bits: default_attestation_subnet_prefix_bits(), + attestation_subnet_prefix_bits: compute_attestation_subnet_prefix_bits( + default_attestation_subnet_count(), + default_attestation_subnet_extra_bits(), + ), /* * Networking Deneb Specific @@ -1602,9 +1800,9 @@ impl ChainSpec { * Networking Fulu specific */ blob_schedule: BlobSchedule::default(), - min_epochs_for_data_column_sidecars_requests: - default_min_epochs_for_data_column_sidecars_requests(), + min_epochs_for_data_column_sidecars_requests: 16384, max_data_columns_by_root_request: default_data_columns_by_root_request(), + max_payload_envelopes_by_root_request: default_max_payload_envelopes_by_root_request(), /* * Application specific @@ -1662,7 +1860,7 @@ impl<'de> Deserialize<'de> for BlobSchedule { impl BlobSchedule { pub fn new(mut vec: Vec<BlobParameters>) -> Self { // reverse sort by epoch - vec.sort_by(|a, b| b.epoch.cmp(&a.epoch)); + vec.sort_by_key(|b| std::cmp::Reverse(b.epoch)); Self { schedule: vec, skip_serializing: false, @@ -1832,8 +2030,12 @@ pub struct Config { #[serde(deserialize_with = "deserialize_fork_epoch")] pub gloas_fork_epoch: Option<MaybeQuoted<Epoch>>, - #[serde(with = "serde_utils::quoted_u64")] - seconds_per_slot: u64, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + seconds_per_slot: Option<MaybeQuoted<u64>>, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + slot_duration_ms: Option<MaybeQuoted<u64>>, #[serde(with = "serde_utils::quoted_u64")] seconds_per_eth1_block: u64, #[serde(with = "serde_utils::quoted_u64")] @@ -1863,6 +2065,13 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] proposer_score_boost: Option<MaybeQuoted<u64>>, + #[serde(skip_serializing_if = "Option::is_none")] + reorg_head_weight_threshold: Option<MaybeQuoted<u64>>, + #[serde(skip_serializing_if = "Option::is_none")] + reorg_parent_weight_threshold: Option<MaybeQuoted<u64>>, + #[serde(skip_serializing_if = "Option::is_none")] + reorg_max_epochs_since_finalization: Option<MaybeQuoted<u64>>, + #[serde(with = "serde_utils::quoted_u64")] deposit_chain_id: u64, #[serde(with = "serde_utils::quoted_u64")] @@ -1901,9 +2110,15 @@ pub struct Config { #[serde(default = "default_message_domain_valid_snappy")] #[serde(with = "serde_utils::bytes_4_hex")] message_domain_valid_snappy: [u8; 4], - #[serde(default = "default_attestation_subnet_prefix_bits")] + #[serde(default = "default_epochs_per_subnet_subscription")] + #[serde(with = "serde_utils::quoted_u64")] + epochs_per_subnet_subscription: u64, + #[serde(default = "default_attestation_subnet_count")] + #[serde(with = "serde_utils::quoted_u64")] + attestation_subnet_count: u64, + #[serde(default = "default_attestation_subnet_extra_bits")] #[serde(with = "serde_utils::quoted_u8")] - attestation_subnet_prefix_bits: u8, + attestation_subnet_extra_bits: u8, #[serde(default = "default_max_request_blocks_deneb")] #[serde(with = "serde_utils::quoted_u64")] max_request_blocks_deneb: u64, @@ -1963,6 +2178,42 @@ pub struct Config { #[serde(default = "default_min_epochs_for_data_column_sidecars_requests")] #[serde(with = "serde_utils::quoted_u64")] min_epochs_for_data_column_sidecars_requests: u64, + + #[serde(default = "default_proposer_reorg_cutoff_bps")] + #[serde(with = "serde_utils::quoted_u64")] + proposer_reorg_cutoff_bps: u64, + #[serde(default = "default_attestation_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + attestation_due_bps: u64, + #[serde(default = "default_attestation_due_bps_gloas")] + #[serde(with = "serde_utils::quoted_u64")] + attestation_due_bps_gloas: u64, + #[serde(default = "default_payload_attestation_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + payload_attestation_due_bps: u64, + #[serde(default = "default_aggregate_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + aggregate_due_bps: u64, + #[serde(default = "default_sync_message_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + sync_message_due_bps: u64, + #[serde(default = "default_contribution_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + contribution_due_bps: u64, + + #[serde(default = "default_min_builder_withdrawability_delay")] + #[serde(with = "serde_utils::quoted_u64")] + min_builder_withdrawability_delay: u64, + + #[serde(default = "default_churn_limit_quotient_gloas")] + #[serde(with = "serde_utils::quoted_u64")] + churn_limit_quotient_gloas: u64, + #[serde(default = "default_consolidation_churn_limit_quotient")] + #[serde(with = "serde_utils::quoted_u64")] + consolidation_churn_limit_quotient: u64, + #[serde(default = "default_max_per_epoch_activation_churn_limit_gloas")] + #[serde(with = "serde_utils::quoted_u64")] + max_per_epoch_activation_churn_limit_gloas: u64, } fn default_bellatrix_fork_version() -> [u8; 4] { @@ -2023,8 +2274,36 @@ fn default_subnets_per_node() -> u8 { 2u8 } -fn default_attestation_subnet_prefix_bits() -> u8 { - 6 +const fn default_epochs_per_subnet_subscription() -> u64 { + 256 +} + +const fn default_attestation_subnet_count() -> u64 { + 64 +} + +const fn default_attestation_subnet_extra_bits() -> u8 { + 0 +} + +/// Compute attestation_subnet_prefix_bits dynamically as: +/// ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS +fn compute_attestation_subnet_prefix_bits( + attestation_subnet_count: u64, + attestation_subnet_extra_bits: u8, +) -> u8 { + let default_attestation_subnet_prefix_bits = 6u8; + + // ceillog2() = next_power_of_two().ilog2() + // casting to u8 is fine given ilog2(u64::MAX) = 63 + let min_bits_needed = attestation_subnet_count + .checked_next_power_of_two() + .and_then(|x| x.checked_ilog2()) + .unwrap_or(default_attestation_subnet_prefix_bits as u32) as u8; + + min_bits_needed + .safe_add(attestation_subnet_extra_bits) + .unwrap_or(default_attestation_subnet_prefix_bits) } const fn default_max_per_epoch_activation_churn_limit() -> u64 { @@ -2145,6 +2424,50 @@ const fn default_min_epochs_for_data_column_sidecars_requests() -> u64 { 4096 } +const fn default_proposer_reorg_cutoff_bps() -> u64 { + 1667 +} + +const fn default_attestation_due_bps() -> u64 { + 3333 +} + +const fn default_attestation_due_bps_gloas() -> u64 { + 2500 +} + +const fn default_payload_attestation_due_bps() -> u64 { + 7500 +} + +const fn default_aggregate_due_bps() -> u64 { + 6667 +} + +const fn default_sync_message_due_bps() -> u64 { + 3333 +} + +const fn default_contribution_due_bps() -> u64 { + 6667 +} + +const fn default_min_builder_withdrawability_delay() -> u64 { + 64 +} + +const fn default_churn_limit_quotient_gloas() -> u64 { + 32_768 +} + +const fn default_consolidation_churn_limit_quotient() -> u64 { + 65_536 +} + +const fn default_max_per_epoch_activation_churn_limit_gloas() -> u64 { + 256_000_000_000 +} + fn max_blocks_by_root_request_common(max_request_blocks: u64) -> usize { let max_request_blocks = max_request_blocks as usize; RuntimeVariableList::<Hash256>::new( @@ -2156,37 +2479,41 @@ fn max_blocks_by_root_request_common(max_request_blocks: u64) -> usize { .len() } -fn max_blobs_by_root_request_common(max_request_blob_sidecars: u64) -> usize { - let max_request_blob_sidecars = max_request_blob_sidecars as usize; - let empty_blob_identifier = BlobIdentifier { - block_root: Hash256::zero(), - index: 0, - }; +// Simplified function which precomputes the size of a `List` of `BlobIdentifiers`. +pub(crate) fn max_blobs_by_root_request_common(max_request_blob_sidecars: u64) -> usize { + // BlobIdentifier is a fixed-size struct with two fields: + // - block_root: Hash256 (32 bytes) + // - index: u64 (8 bytes) + // Total per element: 32 + 8 = 40 bytes + // Since BlobIdentifier is fixed-size, the outer List does not add any byte overhead. + let blob_identifier_ssz_size = 40_usize; - RuntimeVariableList::<BlobIdentifier>::new( - vec![empty_blob_identifier; max_request_blob_sidecars], - max_request_blob_sidecars, - ) - .expect("creating a RuntimeVariableList of size `max_request_blob_sidecars` should succeed") - .as_ssz_bytes() - .len() + (max_request_blob_sidecars as usize) + .safe_mul(blob_identifier_ssz_size) + .expect("should not overflow") } -fn max_data_columns_by_root_request_common<E: EthSpec>(max_request_blocks: u64) -> usize { - let max_request_blocks = max_request_blocks as usize; +// Simplified function which precomputes the size of a `List` of `DataColumnIdentifiers`. +pub(crate) fn max_data_columns_by_root_request_common<E: EthSpec>( + max_request_blocks: u64, +) -> usize { + // DataColumnsByRootIdentifier is a variable-size struct with two fields: + // - block_root: Hash256 (32 bytes) + // - columns: List<ColumnIndex, NumberOfColumns> (4 byte offset + n × 8 bytes) + // Since DataColumnsByRootIdentifier is variable-size, the outer List adds a + // 4-byte offset per element. + // Total per element: 4 (outer offset) + 32 (block_root) + 4 (columns offset) + n × 8 + let column_index_ssz_size = 8_usize; + let ssz_fixed_size = 40_usize; - let empty_data_columns_by_root_id = DataColumnsByRootIdentifier { - block_root: Hash256::zero(), - columns: VariableList::repeat_full(0), - }; + let data_columns_by_root_identifier_ssz_size = column_index_ssz_size + .safe_mul(E::number_of_columns()) + .and_then(|b| b.safe_add(ssz_fixed_size)) + .expect("should not overflow"); - RuntimeVariableList::<DataColumnsByRootIdentifier<E>>::new( - vec![empty_data_columns_by_root_id; max_request_blocks], - max_request_blocks, - ) - .expect("creating a RuntimeVariableList of size `max_request_blocks` should succeed") - .as_ssz_bytes() - .len() + (max_request_blocks as usize) + .safe_mul(data_columns_by_root_identifier_ssz_size) + .expect("should not overflow") } fn default_max_blocks_by_root_request() -> usize { @@ -2205,6 +2532,14 @@ fn default_data_columns_by_root_request() -> usize { max_data_columns_by_root_request_common::<MainnetEthSpec>(default_max_request_blocks_deneb()) } +fn default_max_payload_envelopes_by_root_request() -> usize { + max_blocks_by_root_request_common(default_max_request_payloads()) +} + +fn default_max_request_payloads() -> u64 { + 128 +} + impl Default for Config { fn default() -> Self { let chain_spec = MainnetEthSpec::default_spec(); @@ -2308,13 +2643,20 @@ impl Config { .gloas_fork_epoch .map(|epoch| MaybeQuoted { value: epoch }), - seconds_per_slot: spec.seconds_per_slot, + seconds_per_slot: Some(MaybeQuoted { + value: spec.seconds_per_slot, + }), + slot_duration_ms: Some(MaybeQuoted { + value: spec.slot_duration_ms, + }), seconds_per_eth1_block: spec.seconds_per_eth1_block, min_validator_withdrawability_delay: spec.min_validator_withdrawability_delay, shard_committee_period: spec.shard_committee_period, eth1_follow_distance: spec.eth1_follow_distance, subnets_per_node: spec.subnets_per_node, - attestation_subnet_prefix_bits: spec.attestation_subnet_prefix_bits, + epochs_per_subnet_subscription: spec.epochs_per_subnet_subscription, + attestation_subnet_count: spec.attestation_subnet_count, + attestation_subnet_extra_bits: spec.attestation_subnet_extra_bits, inactivity_score_bias: spec.inactivity_score_bias, inactivity_score_recovery_rate: spec.inactivity_score_recovery_rate, @@ -2324,6 +2666,15 @@ impl Config { max_per_epoch_activation_churn_limit: spec.max_per_epoch_activation_churn_limit, proposer_score_boost: spec.proposer_score_boost.map(|value| MaybeQuoted { value }), + reorg_head_weight_threshold: spec + .reorg_head_weight_threshold + .map(|value| MaybeQuoted { value }), + reorg_parent_weight_threshold: spec + .reorg_parent_weight_threshold + .map(|value| MaybeQuoted { value }), + reorg_max_epochs_since_finalization: spec + .reorg_max_epochs_since_finalization + .map(|value| MaybeQuoted { value }), deposit_chain_id: spec.deposit_chain_id, deposit_network_id: spec.deposit_network_id, @@ -2363,13 +2714,28 @@ impl Config { balance_per_additional_custody_group: spec.balance_per_additional_custody_group, min_epochs_for_data_column_sidecars_requests: spec .min_epochs_for_data_column_sidecars_requests, + + proposer_reorg_cutoff_bps: spec.proposer_reorg_cutoff_bps, + attestation_due_bps: spec.attestation_due_bps, + attestation_due_bps_gloas: spec.attestation_due_bps_gloas, + payload_attestation_due_bps: spec.payload_attestation_due_bps, + aggregate_due_bps: spec.aggregate_due_bps, + sync_message_due_bps: spec.sync_message_due_bps, + contribution_due_bps: spec.contribution_due_bps, + + min_builder_withdrawability_delay: spec.min_builder_withdrawability_delay.as_u64(), + + churn_limit_quotient_gloas: spec.churn_limit_quotient_gloas, + consolidation_churn_limit_quotient: spec.consolidation_churn_limit_quotient, + max_per_epoch_activation_churn_limit_gloas: spec + .max_per_epoch_activation_churn_limit_gloas, } } pub fn from_file(filename: &Path) -> Result<Self, String> { let f = File::open(filename) .map_err(|e| format!("Error opening spec at {}: {:?}", filename.display(), e))?; - serde_yaml::from_reader(f) + yaml_serde::from_reader(f) .map_err(|e| format!("Error parsing spec at {}: {:?}", filename.display(), e)) } @@ -2402,12 +2768,15 @@ impl Config { gloas_fork_version, gloas_fork_epoch, seconds_per_slot, + slot_duration_ms, seconds_per_eth1_block, min_validator_withdrawability_delay, shard_committee_period, eth1_follow_distance, subnets_per_node, - attestation_subnet_prefix_bits, + epochs_per_subnet_subscription, + attestation_subnet_count, + attestation_subnet_extra_bits, inactivity_score_bias, inactivity_score_recovery_rate, ejection_balance, @@ -2415,6 +2784,9 @@ impl Config { max_per_epoch_activation_churn_limit, churn_limit_quotient, proposer_score_boost, + reorg_head_weight_threshold, + reorg_parent_weight_threshold, + reorg_max_epochs_since_finalization, deposit_chain_id, deposit_network_id, deposit_contract_address, @@ -2448,13 +2820,32 @@ impl Config { validator_custody_requirement, balance_per_additional_custody_group, min_epochs_for_data_column_sidecars_requests, + proposer_reorg_cutoff_bps, + attestation_due_bps, + attestation_due_bps_gloas, + payload_attestation_due_bps, + aggregate_due_bps, + sync_message_due_bps, + contribution_due_bps, + min_builder_withdrawability_delay, + churn_limit_quotient_gloas, + consolidation_churn_limit_quotient, + max_per_epoch_activation_churn_limit_gloas, } = self; if preset_base != E::spec_name().to_string().as_str() { return None; } - Some(ChainSpec { + // Fail if seconds_per_slot and slot_duration_ms are both set but are inconsistent. + if let (Some(seconds_per_slot), Some(slot_duration_ms)) = + (seconds_per_slot, slot_duration_ms) + && seconds_per_slot.value.saturating_mul(1000) != slot_duration_ms.value + { + return None; + } + + let spec = ChainSpec { config_name: config_name.clone(), min_genesis_active_validator_count, min_genesis_time, @@ -2476,12 +2867,20 @@ impl Config { fulu_fork_version, gloas_fork_version, gloas_fork_epoch: gloas_fork_epoch.map(|q| q.value), - seconds_per_slot, + seconds_per_slot: seconds_per_slot + .map(|q| q.value) + .or_else(|| slot_duration_ms.and_then(|q| q.value.checked_div(1000)))?, + slot_duration_ms: slot_duration_ms + .map(|q| q.value) + .or_else(|| seconds_per_slot.map(|q| q.value.saturating_mul(1000)))?, seconds_per_eth1_block, min_validator_withdrawability_delay, shard_committee_period, eth1_follow_distance, subnets_per_node, + epochs_per_subnet_subscription, + attestation_subnet_count, + attestation_subnet_extra_bits, inactivity_score_bias, inactivity_score_recovery_rate, ejection_balance, @@ -2489,6 +2888,10 @@ impl Config { max_per_epoch_activation_churn_limit, churn_limit_quotient, proposer_score_boost: proposer_score_boost.map(|q| q.value), + reorg_head_weight_threshold: reorg_head_weight_threshold.map(|q| q.value), + reorg_parent_weight_threshold: reorg_parent_weight_threshold.map(|q| q.value), + reorg_max_epochs_since_finalization: reorg_max_epochs_since_finalization + .map(|q| q.value), deposit_chain_id, deposit_network_id, deposit_contract_address, @@ -2502,7 +2905,6 @@ impl Config { resp_timeout, message_domain_invalid_snappy, message_domain_valid_snappy, - attestation_subnet_prefix_bits, max_request_blocks, attestation_propagation_slot_range, maximum_gossip_clock_disparity, @@ -2519,16 +2921,6 @@ impl Config { max_request_blob_sidecars_electra, blob_sidecar_subnet_count_electra, - // We need to re-derive any values that might have changed in the config. - max_blocks_by_root_request: max_blocks_by_root_request_common(max_request_blocks), - max_blocks_by_root_request_deneb: max_blocks_by_root_request_common( - max_request_blocks_deneb, - ), - max_blobs_by_root_request: max_blobs_by_root_request_common(max_request_blob_sidecars), - max_data_columns_by_root_request: max_data_columns_by_root_request_common::<E>( - max_request_blocks_deneb, - ), - number_of_custody_groups, data_column_sidecar_subnet_count, samples_per_slot, @@ -2538,8 +2930,23 @@ impl Config { balance_per_additional_custody_group, min_epochs_for_data_column_sidecars_requests, + proposer_reorg_cutoff_bps, + attestation_due_bps, + attestation_due_bps_gloas, + payload_attestation_due_bps, + aggregate_due_bps, + sync_message_due_bps, + contribution_due_bps, + + min_builder_withdrawability_delay: Epoch::new(min_builder_withdrawability_delay), + + churn_limit_quotient_gloas, + consolidation_churn_limit_quotient, + max_per_epoch_activation_churn_limit_gloas, + ..chain_spec.clone() - }) + }; + Some(spec.compute_derived_values::<E>()) } } @@ -2677,6 +3084,13 @@ mod tests { } } } + + #[test] + fn test_compute_min_bits_for_n_values_edge_cases() { + assert_eq!(compute_attestation_subnet_prefix_bits(64, 0), 6); + assert_eq!(compute_attestation_subnet_prefix_bits(65, 0), 7); + assert_eq!(compute_attestation_subnet_prefix_bits(0, 1), 1); + } } #[cfg(test)] @@ -2684,6 +3098,9 @@ mod yaml_tests { use super::*; use crate::core::MinimalEthSpec; use paste::paste; + use std::collections::BTreeSet; + use std::env; + use std::path::PathBuf; use std::sync::Arc; use tempfile::NamedTempFile; @@ -2700,7 +3117,7 @@ mod yaml_tests { let yamlconfig = Config::from_chain_spec::<MinimalEthSpec>(&minimal_spec); // write fresh minimal config to file - serde_yaml::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); + yaml_serde::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); let reader = File::options() .read(true) @@ -2708,7 +3125,7 @@ mod yaml_tests { .open(tmp_file.as_ref()) .expect("error while opening the file"); // deserialize minimal config from file - let from: Config = serde_yaml::from_reader(reader).expect("error while deserializing"); + let from: Config = yaml_serde::from_reader(reader).expect("error while deserializing"); assert_eq!(from, yamlconfig); } @@ -2722,17 +3139,78 @@ mod yaml_tests { .expect("error opening file"); let mainnet_spec = ChainSpec::mainnet(); let yamlconfig = Config::from_chain_spec::<MainnetEthSpec>(&mainnet_spec); - serde_yaml::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); + yaml_serde::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); let reader = File::options() .read(true) .write(false) .open(tmp_file.as_ref()) .expect("error while opening the file"); - let from: Config = serde_yaml::from_reader(reader).expect("error while deserializing"); + let from: Config = yaml_serde::from_reader(reader).expect("error while deserializing"); assert_eq!(from, yamlconfig); } + #[test] + fn slot_duration_fallback_both_fields() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::<MainnetEthSpec>(&mainnet); + config.seconds_per_slot = Some(MaybeQuoted { value: 12 }); + config.slot_duration_ms = Some(MaybeQuoted { value: 12000 }); + let spec = config + .apply_to_chain_spec::<MainnetEthSpec>(&mainnet) + .unwrap(); + assert_eq!(spec.seconds_per_slot, 12); + assert_eq!(spec.slot_duration_ms, 12000); + } + + #[test] + fn slot_duration_fallback_both_fields_inconsistent() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::<MainnetEthSpec>(&mainnet); + config.seconds_per_slot = Some(MaybeQuoted { value: 10 }); + config.slot_duration_ms = Some(MaybeQuoted { value: 12000 }); + assert_eq!(config.apply_to_chain_spec::<MainnetEthSpec>(&mainnet), None); + } + + #[test] + fn slot_duration_fallback_seconds_only() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::<MainnetEthSpec>(&mainnet); + config.seconds_per_slot = Some(MaybeQuoted { value: 12 }); + config.slot_duration_ms = None; + let spec = config + .apply_to_chain_spec::<MainnetEthSpec>(&mainnet) + .unwrap(); + assert_eq!(spec.seconds_per_slot, 12); + assert_eq!(spec.slot_duration_ms, 12000); + } + + #[test] + fn slot_duration_fallback_ms_only() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::<MainnetEthSpec>(&mainnet); + config.seconds_per_slot = None; + config.slot_duration_ms = Some(MaybeQuoted { value: 12000 }); + let spec = config + .apply_to_chain_spec::<MainnetEthSpec>(&mainnet) + .unwrap(); + assert_eq!(spec.seconds_per_slot, 12); + assert_eq!(spec.slot_duration_ms, 12000); + } + + #[test] + fn slot_duration_fallback_neither() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::<MainnetEthSpec>(&mainnet); + config.seconds_per_slot = None; + config.slot_duration_ms = None; + assert!( + config + .apply_to_chain_spec::<MainnetEthSpec>(&mainnet) + .is_none() + ); + } + #[test] fn blob_schedule_max_blobs_per_block() { let spec_contents = r#" @@ -2742,6 +3220,7 @@ mod yaml_tests { GENESIS_FORK_VERSION: 0x10355025 GENESIS_DELAY: 60 SECONDS_PER_SLOT: 12 + SLOT_DURATION_MS: 12000 SECONDS_PER_ETH1_BLOCK: 12 MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 SHARD_COMMITTEE_PERIOD: 256 @@ -2756,6 +3235,9 @@ mod yaml_tests { REORG_HEAD_WEIGHT_THRESHOLD: 20 REORG_PARENT_WEIGHT_THRESHOLD: 160 REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 + ATTESTATION_SUBNET_COUNT: 64 + ATTESTATION_SUBNET_EXTRA_BITS: 0 DEPOSIT_CHAIN_ID: 7042643276 DEPOSIT_NETWORK_ID: 7042643276 DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa @@ -2787,7 +3269,7 @@ mod yaml_tests { MAX_BLOBS_PER_BLOCK: 20 "#; let config: Config = - serde_yaml::from_str(spec_contents).expect("error while deserializing"); + yaml_serde::from_str(spec_contents).expect("error while deserializing"); let spec = ChainSpec::from_config::<MainnetEthSpec>(&config).expect("error while creating spec"); @@ -2869,11 +3351,11 @@ mod yaml_tests { assert_eq!(spec.max_blobs_per_block_within_fork(ForkName::Fulu), 20); // Check that serialization is in ascending order - let yaml = serde_yaml::to_string(&spec.blob_schedule).expect("should serialize"); + let yaml = yaml_serde::to_string(&spec.blob_schedule).expect("should serialize"); // Deserialize back to Vec<BlobParameters> to check order let deserialized: Vec<BlobParameters> = - serde_yaml::from_str(&yaml).expect("should deserialize"); + yaml_serde::from_str(&yaml).expect("should deserialize"); // Should be in ascending order by epoch assert!( @@ -2891,6 +3373,7 @@ mod yaml_tests { GENESIS_FORK_VERSION: 0x10355025 GENESIS_DELAY: 60 SECONDS_PER_SLOT: 12 + SLOT_DURATION_MS: 12000 SECONDS_PER_ETH1_BLOCK: 12 MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 SHARD_COMMITTEE_PERIOD: 256 @@ -2905,6 +3388,9 @@ mod yaml_tests { REORG_HEAD_WEIGHT_THRESHOLD: 20 REORG_PARENT_WEIGHT_THRESHOLD: 160 REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 + ATTESTATION_SUBNET_COUNT: 64 + ATTESTATION_SUBNET_EXTRA_BITS: 0 DEPOSIT_CHAIN_ID: 7042643276 DEPOSIT_NETWORK_ID: 7042643276 DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa @@ -2936,7 +3422,7 @@ mod yaml_tests { MAX_BLOBS_PER_BLOCK: 300 "#; let config: Config = - serde_yaml::from_str(spec_contents).expect("error while deserializing"); + yaml_serde::from_str(spec_contents).expect("error while deserializing"); let spec = ChainSpec::from_config::<MainnetEthSpec>(&config).expect("error while creating spec"); @@ -3003,6 +3489,7 @@ mod yaml_tests { SHARDING_FORK_VERSION: 0x03000000 SHARDING_FORK_EPOCH: 18446744073709551615 SECONDS_PER_SLOT: 12 + SLOT_DURATION_MS: 12000 SECONDS_PER_ETH1_BLOCK: 14 MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 SHARD_COMMITTEE_PERIOD: 256 @@ -3014,6 +3501,9 @@ mod yaml_tests { MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 CHURN_LIMIT_QUOTIENT: 65536 PROPOSER_SCORE_BOOST: 40 + EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 + ATTESTATION_SUBNET_COUNT: 64 + ATTESTATION_SUBNET_EXTRA_BITS: 0 DEPOSIT_CHAIN_ID: 1 DEPOSIT_NETWORK_ID: 1 DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa @@ -3022,7 +3512,7 @@ mod yaml_tests { SAMPLES_PER_SLOT: 8 "#; - let chain_spec: Config = serde_yaml::from_str(spec).unwrap(); + let chain_spec: Config = yaml_serde::from_str(spec).unwrap(); // Asserts that `chain_spec.$name` and `default_$name()` are equal. macro_rules! check_default { @@ -3046,7 +3536,6 @@ mod yaml_tests { check_default!(resp_timeout); check_default!(message_domain_invalid_snappy); check_default!(message_domain_valid_snappy); - check_default!(attestation_subnet_prefix_bits); assert_eq!(chain_spec.bellatrix_fork_epoch, None); } @@ -3124,17 +3613,19 @@ mod yaml_tests { spec.min_epoch_data_availability_boundary(fulu_fork_epoch) ); - // `min_epochs_for_data_sidecar_requests` at fulu fork epoch + min_epochs_for_blob_sidecars_request - let blob_retention_epoch_after_fulu = fulu_fork_epoch + blob_retention_epochs; - let expected_blob_retention_epoch = blob_retention_epoch_after_fulu - blob_retention_epochs; + // Now, the blob retention period starts still before the fulu fork epoch, so the boundary + // should respect the blob retention period. + let half_blob_retention_epoch_after_fulu = fulu_fork_epoch + (blob_retention_epochs / 2); + let expected_blob_retention_epoch = + half_blob_retention_epoch_after_fulu - blob_retention_epochs; assert_eq!( Some(expected_blob_retention_epoch), - spec.min_epoch_data_availability_boundary(blob_retention_epoch_after_fulu) + spec.min_epoch_data_availability_boundary(half_blob_retention_epoch_after_fulu) ); - // After the final blob retention epoch, `min_epochs_for_data_sidecar_requests` should be calculated - // using `min_epochs_for_data_column_sidecars_request` - let current_epoch = blob_retention_epoch_after_fulu + 1; + // If the retention period starts with the fulu fork epoch, there are no more blobs to + // retain, and the return value will be based on the data column retention period. + let current_epoch = fulu_fork_epoch + blob_retention_epochs; let expected_data_column_retention_epoch = current_epoch - data_column_retention_epochs; assert_eq!( Some(expected_data_column_retention_epoch), @@ -3142,6 +3633,39 @@ mod yaml_tests { ); } + #[test] + fn min_epochs_for_data_sidecar_requests_fulu_genesis() { + type E = MainnetEthSpec; + let spec = { + // fulu active at genesis + let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + // set a different value for testing purpose, 4096 / 2 = 2048 + spec.min_epochs_for_data_column_sidecars_requests = + spec.min_epochs_for_blob_sidecars_requests / 2; + Arc::new(spec) + }; + let blob_retention_epochs = spec.min_epochs_for_blob_sidecars_requests; + let data_column_retention_epochs = spec.min_epochs_for_data_column_sidecars_requests; + + // If Fulu is activated at genesis, the column retention period should always be used. + let assert_correct_boundary = |epoch| { + let epoch = Epoch::new(epoch); + assert_eq!( + Some(epoch.saturating_sub(data_column_retention_epochs)), + spec.min_epoch_data_availability_boundary(epoch) + ) + }; + + assert_correct_boundary(0); + assert_correct_boundary(1); + assert_correct_boundary(blob_retention_epochs - 1); + assert_correct_boundary(blob_retention_epochs); + assert_correct_boundary(blob_retention_epochs + 1); + assert_correct_boundary(data_column_retention_epochs - 1); + assert_correct_boundary(data_column_retention_epochs); + assert_correct_boundary(data_column_retention_epochs + 1); + } + #[test] fn proposer_shuffling_decision_root_around_epoch_boundary() { type E = MainnetEthSpec; @@ -3171,4 +3695,258 @@ mod yaml_tests { ); } } + + #[test] + fn test_slot_component_duration_calculations() { + let spec = ChainSpec::mainnet().compute_derived_values::<MainnetEthSpec>(); + + // Test unaggregated attestation (3333 bps = 33.33% of 12s = 4s) + let unagg_due = spec.get_unaggregated_attestation_due(); + assert_eq!(unagg_due, Duration::from_millis(3999)); // 12000 * 3333 / 10000 + + // Test aggregate attestation (6667 bps = 66.67% of 12s = 8s) + let agg_due = spec.get_aggregate_attestation_due(); + assert_eq!(agg_due, Duration::from_millis(8000)); // 12000 * 6667 / 10000 + + // Test sync message (3333 bps = 33.33% of 12s = 4s) + let sync_msg_due = spec.get_sync_message_due(); + assert_eq!(sync_msg_due, Duration::from_millis(3999)); // 12000 * 3333 / 10000 + + // Test contribution message (6667 bps = 66.67% of 12s = 8s) + let contribution_due = spec.get_contribution_message_due(); + assert_eq!(contribution_due, Duration::from_millis(8000)); // 12000 * 6667 / 10000 + + // Test slot duration + let slot_duration = spec.get_slot_duration(); + assert_eq!(slot_duration, Duration::from_millis(12000)); + + // Test edge cases with custom spec + let mut custom_spec = spec.clone(); + + // Edge case: 0 bps should give 0 duration + custom_spec.attestation_due_bps = 0; + let custom_spec = custom_spec.compute_derived_values::<MainnetEthSpec>(); + let zero_due = custom_spec.get_unaggregated_attestation_due(); + assert_eq!(zero_due, Duration::from_millis(0)); + + // Edge case: 10000 bps (100%) should give full slot duration + let mut custom_spec = custom_spec; + custom_spec.attestation_due_bps = 10_000; + let custom_spec = custom_spec.compute_derived_values::<MainnetEthSpec>(); + let full_due = custom_spec.get_unaggregated_attestation_due(); + assert_eq!(full_due, Duration::from_millis(12000)); + + // Edge case: 5000 bps (50%) should give half slot duration + let mut custom_spec = custom_spec; + custom_spec.attestation_due_bps = 5_000; + let custom_spec = custom_spec.compute_derived_values::<MainnetEthSpec>(); + let half_due = custom_spec.get_unaggregated_attestation_due(); + assert_eq!(half_due, Duration::from_millis(6000)); + + // Test with different slot duration (Gnosis: 5s slots) + let mut custom_spec = custom_spec; + custom_spec.slot_duration_ms = 5000; + custom_spec.attestation_due_bps = 3333; + let custom_spec = custom_spec.compute_derived_values::<MainnetEthSpec>(); + let gnosis_due = custom_spec.get_unaggregated_attestation_due(); + assert_eq!(gnosis_due, Duration::from_millis(1666)); // 5000 * 3333 / 10000 + + // Test with very small slot duration + let mut custom_spec = custom_spec; + custom_spec.slot_duration_ms = 1000; // 1 second + custom_spec.attestation_due_bps = 3333; + let custom_spec = custom_spec.compute_derived_values::<MainnetEthSpec>(); + let small_due = custom_spec.get_unaggregated_attestation_due(); + assert_eq!(small_due, Duration::from_millis(333)); // 1000 * 3333 / 10000 + + // Test rounding behavior with non-divisible values + let mut custom_spec = custom_spec; + custom_spec.slot_duration_ms = 12000; + custom_spec.attestation_due_bps = 1; // 0.01% + let custom_spec = custom_spec.compute_derived_values::<MainnetEthSpec>(); + let tiny_due = custom_spec.get_unaggregated_attestation_due(); + assert_eq!(tiny_due, Duration::from_millis(1)); // 12000 * 1 / 10000 = 1.2 -> 1 + } + + #[test] + fn test_default_duration_values_without_compute_derived_values() { + // Verify that mainnet, minimal, and gnosis have correct pre-computed defaults + // without needing to call compute_derived_values() + let mainnet = ChainSpec::mainnet(); + assert_eq!( + mainnet.get_unaggregated_attestation_due(), + Duration::from_millis(3999) + ); + assert_eq!( + mainnet.get_aggregate_attestation_due(), + Duration::from_millis(8000) + ); + assert_eq!(mainnet.get_sync_message_due(), Duration::from_millis(3999)); + assert_eq!( + mainnet.get_contribution_message_due(), + Duration::from_millis(8000) + ); + + // Minimal spec: 6000ms slots, 3333 bps = 1999ms, 6667 bps = 4000ms + let minimal = ChainSpec::minimal(); + assert_eq!( + minimal.get_unaggregated_attestation_due(), + Duration::from_millis(1999) + ); + assert_eq!( + minimal.get_aggregate_attestation_due(), + Duration::from_millis(4000) + ); + assert_eq!(minimal.get_sync_message_due(), Duration::from_millis(1999)); + assert_eq!( + minimal.get_contribution_message_due(), + Duration::from_millis(4000) + ); + + // Gnosis spec: 5000ms slots, 3333 bps = 1666ms, 6667 bps = 3333ms + let gnosis = ChainSpec::gnosis(); + assert_eq!( + gnosis.get_unaggregated_attestation_due(), + Duration::from_millis(1666) + ); + assert_eq!( + gnosis.get_aggregate_attestation_due(), + Duration::from_millis(3333) + ); + assert_eq!(gnosis.get_sync_message_due(), Duration::from_millis(1666)); + assert_eq!( + gnosis.get_contribution_message_due(), + Duration::from_millis(3333) + ); + } + + #[test] + #[should_panic(expected = "exceeds slot duration")] + fn test_compute_derived_values_panics_on_invalid_bps_values() { + let mut spec = ChainSpec::mainnet(); + // 15000 bps = 150% of slot duration, which is invalid + spec.attestation_due_bps = 15000; + spec.compute_derived_values::<MainnetEthSpec>(); + } + + fn configs_base_path() -> PathBuf { + env::var("CARGO_MANIFEST_DIR") + .expect("should know manifest dir") + .parse::<PathBuf>() + .expect("should parse manifest dir as path") + .join("configs") + } + + /// Upstream config keys that Lighthouse intentionally does not include in its + /// `Config` struct. These are forks/features not yet implemented. Update this + /// list as new forks are added. + const UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE: &[&str] = &[ + // Forks not yet implemented + "HEZE_FORK_VERSION", + "HEZE_FORK_EPOCH", + "EIP7928_FORK_VERSION", + "EIP7928_FORK_EPOCH", + // Gloas params not yet in Config + "AGGREGATE_DUE_BPS_GLOAS", + "SYNC_MESSAGE_DUE_BPS_GLOAS", + "CONTRIBUTION_DUE_BPS_GLOAS", + "MAX_REQUEST_PAYLOADS", + // Heze networking + "INCLUSION_LIST_DUE_BPS", + "MAX_REQUEST_INCLUSION_LIST", + "MAX_BYTES_PER_INCLUSION_LIST", + ]; + + /// Compare a `ChainSpec` against an upstream consensus-specs config YAML file. + /// + /// 1. Extracts keys from the raw YAML text (to avoid yaml_serde's inability + /// to parse integers > u64 into `Value`/`Mapping` types) and checks that + /// every key is either known to `Config` or explicitly listed in + /// `UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE`. + /// 2. Deserializes the upstream YAML as `Config` (which has custom + /// deserializers for large values like `TERMINAL_TOTAL_DIFFICULTY`) and + /// compares against `Config::from_chain_spec`. + fn config_test<E: EthSpec>(spec: &ChainSpec, config_name: &str) { + let file_path = configs_base_path().join(format!("{config_name}.yaml")); + let upstream_yaml = std::fs::read_to_string(&file_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", file_path.display())); + + // Extract top-level keys from the raw YAML text. We can't parse as + // yaml_serde::Mapping because yaml_serde cannot represent integers + // exceeding u64 (e.g. TERMINAL_TOTAL_DIFFICULTY). Config YAML uses a + // simple `KEY: value` format with no indentation for top-level keys. + let upstream_keys: BTreeSet<String> = upstream_yaml + .lines() + .filter_map(|line| { + // Skip comments, blank lines, and indented lines (nested YAML). + if line.is_empty() + || line.starts_with('#') + || line.starts_with(' ') + || line.starts_with('\t') + { + return None; + } + line.split(':').next().map(|k| k.to_string()) + }) + .collect(); + + // Get the set of keys that Config knows about by serializing and collecting + // keys. Also include keys for optional fields that may be skipped during + // serialization (e.g. CONFIG_NAME). + let our_config = Config::from_chain_spec::<E>(spec); + let our_yaml = yaml_serde::to_string(&our_config).expect("failed to serialize Config"); + let our_mapping: yaml_serde::Mapping = + yaml_serde::from_str(&our_yaml).expect("failed to re-parse our Config"); + let mut known_keys: BTreeSet<String> = our_mapping + .keys() + .filter_map(|k| k.as_str().map(String::from)) + .collect(); + // Fields that Config knows but may skip during serialization. + known_keys.insert("CONFIG_NAME".to_string()); + + // Check for upstream keys that our Config doesn't know about. + let mut missing_keys: Vec<&String> = upstream_keys + .iter() + .filter(|k| { + !known_keys.contains(k.as_str()) + && !UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE.contains(&k.as_str()) + }) + .collect(); + missing_keys.sort(); + + assert!( + missing_keys.is_empty(), + "Upstream {config_name} config has keys not present in Lighthouse Config \ + (add to Config or to UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE): {missing_keys:?}" + ); + + // Compare values for all fields Config knows about. + let mut upstream_config: Config = yaml_serde::from_str(&upstream_yaml) + .unwrap_or_else(|e| panic!("failed to parse {config_name} as Config: {e}")); + + // CONFIG_NAME is network metadata (not a spec parameter), so align it + // before comparing. + upstream_config.config_name = our_config.config_name.clone(); + // SECONDS_PER_SLOT is deprecated upstream but we still emit it, so + // fill it in if the upstream YAML omitted it. + if upstream_config.seconds_per_slot.is_none() { + upstream_config.seconds_per_slot = our_config.seconds_per_slot; + } + assert_eq!( + upstream_config, our_config, + "Config mismatch for {config_name}" + ); + } + + #[test] + fn mainnet_config_consistent() { + let spec = ChainSpec::mainnet(); + config_test::<MainnetEthSpec>(&spec, "mainnet"); + } + + #[test] + fn minimal_config_consistent() { + let spec = ChainSpec::minimal(); + config_test::<MinimalEthSpec>(&spec, "minimal"); + } } diff --git a/consensus/types/src/core/config_and_preset.rs b/consensus/types/src/core/config_and_preset.rs index 28f66d361b..b2cd9c889b 100644 --- a/consensus/types/src/core/config_and_preset.rs +++ b/consensus/types/src/core/config_and_preset.rs @@ -136,6 +136,7 @@ pub fn get_extra_fields(spec: &ChainSpec) -> HashMap<String, Value> { let u32_hex = |v: u32| hex_string(&v.to_le_bytes()); let u8_hex = |v: u8| hex_string(&v.to_le_bytes()); hashmap! { + "attestation_subnet_prefix_bits".to_uppercase() => spec.attestation_subnet_prefix_bits.to_string().into(), "bls_withdrawal_prefix".to_uppercase() => u8_hex(spec.bls_withdrawal_prefix_byte), "eth1_address_withdrawal_prefix".to_uppercase() => u8_hex(spec.eth1_address_withdrawal_prefix_byte), "domain_beacon_proposer".to_uppercase() => u32_hex(spec.domain_beacon_proposer), @@ -153,6 +154,10 @@ pub fn get_extra_fields(spec: &ChainSpec) -> HashMap<String, Value> { "domain_sync_committee".to_uppercase() => u32_hex(spec.domain_sync_committee), "domain_sync_committee_selection_proof".to_uppercase() => u32_hex(spec.domain_sync_committee_selection_proof), + "domain_bls_to_execution_change".to_uppercase() => u32_hex(spec.domain_bls_to_execution_change), + "domain_beacon_builder".to_uppercase() => u32_hex(spec.domain_beacon_builder), + "domain_ptc_attester".to_uppercase() => u32_hex(spec.domain_ptc_attester), + "domain_proposer_preferences".to_uppercase() => u32_hex(spec.domain_proposer_preferences), "sync_committee_subnet_count".to_uppercase() => consts::altair::SYNC_COMMITTEE_SUBNET_COUNT.to_string().into(), "target_aggregators_per_sync_subcommittee".to_uppercase() => @@ -194,7 +199,7 @@ mod test { yamlconfig.extra_fields_mut().insert(k3.into(), v3.into()); yamlconfig.extra_fields_mut().insert(k4.into(), v4); - serde_yaml::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); + yaml_serde::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); let reader = File::options() .read(true) @@ -202,7 +207,41 @@ mod test { .open(tmp_file.as_ref()) .expect("error while opening the file"); let from: ConfigAndPresetGloas = - serde_yaml::from_reader(reader).expect("error while deserializing"); + yaml_serde::from_reader(reader).expect("error while deserializing"); assert_eq!(ConfigAndPreset::Gloas(from), yamlconfig); } + + #[test] + fn test_attestation_subnet_prefix_bits_in_extra_fields() { + let mainnet_spec = ChainSpec::mainnet(); + let config = ConfigAndPreset::from_chain_spec::<MainnetEthSpec>(&mainnet_spec); + let extra_fields = config.extra_fields(); + assert!(extra_fields.contains_key("ATTESTATION_SUBNET_PREFIX_BITS")); + + // For mainnet: 64 subnets, 0 extra bits -> ceil(log2(64)) + 0 = 6 + assert_eq!( + extra_fields.get("ATTESTATION_SUBNET_PREFIX_BITS"), + Some(&Value::String("6".to_string())) + ); + } + + // This is not exhaustive, but it can be extended as new fields are added to the spec. + #[test] + fn test_required_spec_fields_exist() { + let mainnet_spec = ChainSpec::mainnet(); + let config = ConfigAndPreset::from_chain_spec::<MainnetEthSpec>(&mainnet_spec); + let json = serde_json::to_value(&config).expect("should serialize"); + let obj = json.as_object().expect("should be an object"); + let required_fields = [ + "EPOCHS_PER_SUBNET_SUBSCRIPTION", + "ATTESTATION_SUBNET_COUNT", + "ATTESTATION_SUBNET_EXTRA_BITS", + "ATTESTATION_SUBNET_PREFIX_BITS", + "UPDATE_TIMEOUT", + "DOMAIN_BLS_TO_EXECUTION_CHANGE", + ]; + for field in required_fields { + assert!(obj.contains_key(field), "Missing required field: {}", field); + } + } } diff --git a/consensus/types/src/core/consts.rs b/consensus/types/src/core/consts.rs index b6d63c47a8..049094da76 100644 --- a/consensus/types/src/core/consts.rs +++ b/consensus/types/src/core/consts.rs @@ -20,8 +20,22 @@ pub mod altair { pub const NUM_FLAG_INDICES: usize = 3; } pub mod bellatrix { - pub const INTERVALS_PER_SLOT: u64 = 3; + pub const BASIS_POINTS: u64 = 10_000; } pub mod deneb { pub use kzg::VERSIONED_HASH_VERSION_KZG; } +pub mod gloas { + pub const BUILDER_INDEX_SELF_BUILD: u64 = u64::MAX; + pub const BUILDER_INDEX_FLAG: u64 = 1 << 40; + + // Fork choice constants + pub type PayloadStatus = u8; + pub const PAYLOAD_STATUS_EMPTY: PayloadStatus = 0; + pub const PAYLOAD_STATUS_FULL: PayloadStatus = 1; + pub const PAYLOAD_STATUS_PENDING: PayloadStatus = 2; + + pub const ATTESTATION_TIMELINESS_INDEX: usize = 0; + pub const PTC_TIMELINESS_INDEX: usize = 1; + pub const NUM_BLOCK_TIMELINESS_DEADLINES: usize = 2; +} diff --git a/consensus/types/src/core/eth_spec.rs b/consensus/types/src/core/eth_spec.rs index 408c1bf6de..109aa2edec 100644 --- a/consensus/types/src/core/eth_spec.rs +++ b/consensus/types/src/core/eth_spec.rs @@ -3,18 +3,15 @@ use std::{ str::FromStr, }; -use safe_arith::SafeArith; +use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use typenum::{ - U0, U1, U2, U4, U8, U16, U17, U32, U64, U128, U256, U512, U625, U1024, U2048, U4096, U8192, - U65536, U131072, U262144, U1048576, U16777216, U33554432, U134217728, U1073741824, - U1099511627776, UInt, Unsigned, bit::B0, + U0, U1, U2, U4, U8, U16, U17, U24, U32, U48, U64, U96, U128, U256, U512, U625, U1024, U2048, + U4096, U8192, U16384, U65536, U131072, U262144, U1048576, U16777216, U33554432, U134217728, + U1073741824, U1099511627776, UInt, Unsigned, bit::B0, }; -use crate::{ - core::{ChainSpec, Epoch}, - state::BeaconStateError, -}; +use crate::core::{ChainSpec, Epoch}; type U5000 = UInt<UInt<UInt<U625, B0>, B0>, B0>; // 625 * 8 = 5000 @@ -125,6 +122,10 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + type CellsPerExtBlob: Unsigned + Clone + Sync + Send + Debug + PartialEq; type NumberOfColumns: Unsigned + Clone + Sync + Send + Debug + PartialEq; type ProposerLookaheadSlots: Unsigned + Clone + Sync + Send + Debug + PartialEq; + /* + * New in Gloas + */ + type BuilderRegistryLimit: Unsigned + Clone + Sync + Send + Debug + PartialEq; /* * Derived values (set these CAREFULLY) */ @@ -175,9 +176,11 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + * New in Gloas */ type PTCSize: Unsigned + Clone + Sync + Send + Debug + PartialEq; + type PtcWindowLength: Unsigned + Clone + Sync + Send + Debug + PartialEq; type MaxPayloadAttestations: Unsigned + Clone + Sync + Send + Debug + PartialEq; type BuilderPendingPaymentsLimit: Unsigned + Clone + Sync + Send + Debug + PartialEq; type BuilderPendingWithdrawalsLimit: Unsigned + Clone + Sync + Send + Debug + PartialEq; + type MaxBuildersPerWithdrawalsSweep: Unsigned + Clone + Sync + Send + Debug + PartialEq; /* * New in Eip7805 @@ -202,7 +205,7 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + fn get_committee_count_per_slot( active_validator_count: usize, spec: &ChainSpec, - ) -> Result<usize, BeaconStateError> { + ) -> Result<usize, ArithError> { Self::get_committee_count_per_slot_with( active_validator_count, spec.max_committees_per_slot, @@ -214,7 +217,7 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + active_validator_count: usize, max_committees_per_slot: usize, target_committee_size: usize, - ) -> Result<usize, BeaconStateError> { + ) -> Result<usize, ArithError> { let slots_per_epoch = Self::SlotsPerEpoch::to_usize(); Ok(std::cmp::max( @@ -442,10 +445,30 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + Self::PTCSize::to_usize() } + /// Returns the `PtcWindowLength` constant for this specification. + fn ptc_window_length() -> usize { + Self::PtcWindowLength::to_usize() + } + /// Returns the `MaxPayloadAttestations` constant for this specification. fn max_payload_attestations() -> usize { Self::MaxPayloadAttestations::to_usize() } + + /// Returns the `MaxBuildersPerWithdrawalsSweep` constant for this specification. + fn max_builders_per_withdrawals_sweep() -> usize { + Self::MaxBuildersPerWithdrawalsSweep::to_usize() + } + + /// Returns the `PAYLOAD_TIMELY_THRESHOLD` constant (PTC_SIZE / 2). + fn payload_timely_threshold() -> usize { + Self::PTCSize::to_usize() / 2 + } + + /// Returns the `DATA_AVAILABILITY_TIMELY_THRESHOLD` constant (PTC_SIZE / 2). + fn data_availability_timely_threshold() -> usize { + Self::PTCSize::to_usize() / 2 + } } /// Macro to inherit some type values from another EthSpec. @@ -503,6 +526,7 @@ impl EthSpec for MainnetEthSpec { type CellsPerExtBlob = U128; type NumberOfColumns = U128; type ProposerLookaheadSlots = U64; // Derived from (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH + type BuilderRegistryLimit = U1099511627776; type SyncSubcommitteeSize = U128; // 512 committee size / 4 sync committee subnet count type MaxPendingAttestations = U4096; // 128 max attestations * 32 slots per epoch type SlotsPerEth1VotingPeriod = U2048; // 64 epochs * 32 slots per epoch @@ -519,8 +543,10 @@ impl EthSpec for MainnetEthSpec { type MaxTransactionsPerInclusionList = U16; type MaxPendingDepositsPerEpoch = U16; type PTCSize = U512; + type PtcWindowLength = U96; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxPayloadAttestations = U4; type InclusionListCommitteeSize = U16; + type MaxBuildersPerWithdrawalsSweep = U16384; fn default_spec() -> ChainSpec { ChainSpec::mainnet() @@ -564,6 +590,9 @@ impl EthSpec for MinimalEthSpec { type NumberOfColumns = U128; type ProposerLookaheadSlots = U16; // Derived from (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH type BuilderPendingPaymentsLimit = U16; // 2 * SLOTS_PER_EPOCH = 2 * 8 = 16 + type PTCSize = U16; + type PtcWindowLength = U24; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH + type MaxBuildersPerWithdrawalsSweep = U16; params_from_eth_spec!(MainnetEthSpec { JustificationBitsLength, @@ -594,10 +623,10 @@ impl EthSpec for MinimalEthSpec { MaxAttestationsElectra, MaxDepositRequestsPerPayload, MaxWithdrawalRequestsPerPayload, - PTCSize, MaxPayloadAttestations, InclusionListCommitteeSize, - MaxTransactionsPerInclusionList + MaxTransactionsPerInclusionList, + BuilderRegistryLimit }); fn default_spec() -> ChainSpec { @@ -670,10 +699,13 @@ impl EthSpec for GnosisEthSpec { type CellsPerExtBlob = U128; type NumberOfColumns = U128; type ProposerLookaheadSlots = U32; // Derived from (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH + type BuilderRegistryLimit = U1099511627776; type PTCSize = U512; + type PtcWindowLength = U48; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxPayloadAttestations = U2; type InclusionListCommitteeSize = U16; type MaxTransactionsPerInclusionList = U16; + type MaxBuildersPerWithdrawalsSweep = U16384; fn default_spec() -> ChainSpec { ChainSpec::gnosis() @@ -698,6 +730,11 @@ mod test { E::proposer_lookahead_slots(), (spec.min_seed_lookahead.as_usize() + 1) * E::slots_per_epoch() as usize ); + assert_eq!( + E::ptc_window_length(), + (spec.min_seed_lookahead.as_usize() + 2) * E::slots_per_epoch() as usize, + "PtcWindowLength must equal (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH" + ); } #[test] diff --git a/consensus/types/src/execution/execution_block_hash.rs b/consensus/types/src/core/execution_block_hash.rs similarity index 96% rename from consensus/types/src/execution/execution_block_hash.rs rename to consensus/types/src/core/execution_block_hash.rs index 91c019ce04..71e63727ee 100644 --- a/consensus/types/src/execution/execution_block_hash.rs +++ b/consensus/types/src/core/execution_block_hash.rs @@ -5,7 +5,10 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode}; -use crate::{core::Hash256, test_utils::TestRandom}; +use crate::{ + core::{Hash256, Hash256Ext}, + test_utils::TestRandom, +}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Default, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] @@ -18,6 +21,12 @@ impl fmt::Debug for ExecutionBlockHash { } } +impl fmt::Display for ExecutionBlockHash { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.0.short().fmt(f) + } +} + impl ExecutionBlockHash { pub fn zero() -> Self { Self(Hash256::zero()) @@ -102,12 +111,6 @@ impl std::str::FromStr for ExecutionBlockHash { } } -impl fmt::Display for ExecutionBlockHash { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.0) - } -} - impl From<Hash256> for ExecutionBlockHash { fn from(hash: Hash256) -> Self { Self(hash) diff --git a/consensus/types/src/core/mod.rs b/consensus/types/src/core/mod.rs index b1bf2cc7a3..9d12b89d44 100644 --- a/consensus/types/src/core/mod.rs +++ b/consensus/types/src/core/mod.rs @@ -5,6 +5,7 @@ mod chain_spec; mod config_and_preset; mod enr_fork_id; mod eth_spec; +mod execution_block_hash; mod graffiti; mod non_zero_usize; mod preset; @@ -25,6 +26,7 @@ pub use config_and_preset::{ }; pub use enr_fork_id::EnrForkId; pub use eth_spec::{EthSpec, EthSpecId, GNOSIS, GnosisEthSpec, MainnetEthSpec, MinimalEthSpec}; +pub use execution_block_hash::ExecutionBlockHash; pub use graffiti::{GRAFFITI_BYTES_LEN, Graffiti, GraffitiString}; pub use non_zero_usize::new_non_zero_usize; pub use preset::{ @@ -36,9 +38,40 @@ pub use signing_data::{SignedRoot, SigningData}; pub use slot_data::SlotData; pub use slot_epoch::{Epoch, Slot}; +#[cfg(test)] +pub(crate) use chain_spec::{ + max_blobs_by_root_request_common, max_data_columns_by_root_request_common, +}; + pub type Hash256 = alloy_primitives::B256; pub type Uint256 = alloy_primitives::U256; pub type Hash64 = alloy_primitives::B64; pub type Address = alloy_primitives::Address; pub type VersionedHash = Hash256; pub type MerkleProof = Vec<Hash256>; + +/// Extension trait for `Hash256` to allow us to implement additional methods on it. +pub trait Hash256Ext { + fn short(&self) -> ShortenedHash<'_>; +} + +impl Hash256Ext for Hash256 { + fn short(&self) -> ShortenedHash<'_> { + ShortenedHash(self) + } +} + +pub struct ShortenedHash<'a>(&'a Hash256); + +impl<'a> std::fmt::Display for ShortenedHash<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let hash: &[u8; 32] = self.0.as_ref(); + write!( + f, + // Format as hex, padded to 2 digits per byte. + // This outputs a consistent "0x1234...abcd" format. + "0x{:02x}{:02x}…{:02x}{:02x}", + hash[0], hash[1], hash[30], hash[31] + ) + } +} diff --git a/consensus/types/src/core/preset.rs b/consensus/types/src/core/preset.rs index 92ddb9e547..d787a3f9e6 100644 --- a/consensus/types/src/core/preset.rs +++ b/consensus/types/src/core/preset.rs @@ -134,6 +134,9 @@ pub struct AltairPreset { pub epochs_per_sync_committee_period: Epoch, #[serde(with = "serde_utils::quoted_u64")] pub min_sync_committee_participants: u64, + #[serde(default = "default_update_timeout")] + #[serde(with = "serde_utils::quoted_u64")] + pub update_timeout: u64, } impl AltairPreset { @@ -145,10 +148,15 @@ impl AltairPreset { sync_committee_size: E::SyncCommitteeSize::to_u64(), epochs_per_sync_committee_period: spec.epochs_per_sync_committee_period, min_sync_committee_participants: spec.min_sync_committee_participants, + update_timeout: spec.update_timeout, } } } +const fn default_update_timeout() -> u64 { + 8192 +} + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub struct BellatrixPreset { @@ -341,11 +349,28 @@ impl FuluPreset { #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] -pub struct GloasPreset {} +pub struct GloasPreset { + #[serde(with = "serde_utils::quoted_u64")] + pub ptc_size: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub max_payload_attestations: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_registry_limit: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_pending_withdrawals_limit: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub max_builders_per_withdrawals_sweep: u64, +} impl GloasPreset { pub fn from_chain_spec<E: EthSpec>(_spec: &ChainSpec) -> Self { - Self {} + Self { + ptc_size: E::ptc_size() as u64, + max_payload_attestations: E::max_payload_attestations() as u64, + builder_registry_limit: E::BuilderRegistryLimit::to_u64(), + builder_pending_withdrawals_limit: E::builder_pending_withdrawals_limit() as u64, + max_builders_per_withdrawals_sweep: E::max_builders_per_withdrawals_sweep() as u64, + } } } @@ -369,7 +394,7 @@ mod test { fn preset_from_file<T: DeserializeOwned>(preset_name: &str, filename: &str) -> T { let f = File::open(presets_base_path().join(preset_name).join(filename)) .expect("preset file exists"); - serde_yaml::from_reader(f).unwrap() + yaml_serde::from_reader(f).unwrap() } fn preset_test<E: EthSpec>() { diff --git a/consensus/types/src/core/slot_epoch.rs b/consensus/types/src/core/slot_epoch.rs index 97457701b1..837391546c 100644 --- a/consensus/types/src/core/slot_epoch.rs +++ b/consensus/types/src/core/slot_epoch.rs @@ -22,7 +22,7 @@ use crate::{ test_utils::TestRandom, }; -#[cfg(feature = "legacy-arith")] +#[cfg(feature = "saturating-arith")] use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Rem, Sub, SubAssign}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] diff --git a/consensus/types/src/core/slot_epoch_macros.rs b/consensus/types/src/core/slot_epoch_macros.rs index eee267355a..1b0c3bcfc1 100644 --- a/consensus/types/src/core/slot_epoch_macros.rs +++ b/consensus/types/src/core/slot_epoch_macros.rs @@ -117,7 +117,7 @@ macro_rules! impl_safe_arith { } // Deprecated: prefer `SafeArith` methods for new code. -#[cfg(feature = "legacy-arith")] +#[cfg(feature = "saturating-arith")] macro_rules! impl_math_between { ($main: ident, $other: ident) => { impl Add<$other> for $main { @@ -321,9 +321,9 @@ macro_rules! impl_common { impl_u64_eq_ord!($type); impl_safe_arith!($type, $type); impl_safe_arith!($type, u64); - #[cfg(feature = "legacy-arith")] + #[cfg(feature = "saturating-arith")] impl_math_between!($type, $type); - #[cfg(feature = "legacy-arith")] + #[cfg(feature = "saturating-arith")] impl_math_between!($type, u64); impl_math!($type); impl_display!($type); diff --git a/consensus/types/src/data/blob_sidecar.rs b/consensus/types/src/data/blob_sidecar.rs index 709e556933..70b95615e5 100644 --- a/consensus/types/src/data/blob_sidecar.rs +++ b/consensus/types/src/data/blob_sidecar.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash, sync::Arc}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; -use kzg::{BYTES_PER_BLOB, BYTES_PER_FIELD_ELEMENT, Blob as KzgBlob, Kzg, KzgCommitment, KzgProof}; +use kzg::{BYTES_PER_BLOB, BYTES_PER_FIELD_ELEMENT, Kzg, KzgCommitment, KzgProof}; use merkle_proof::{MerkleTreeError, merkle_root_from_branch, verify_merkle_proof}; use rand::Rng; use safe_arith::ArithError; @@ -19,9 +19,9 @@ use crate::{ block::{ BLOB_KZG_COMMITMENTS_INDEX, BeaconBlockHeader, SignedBeaconBlock, SignedBeaconBlockHeader, }, + complete_kzg_commitment_merkle_proof, core::{ChainSpec, Epoch, EthSpec, Hash256, Slot}, - data::Blob, - execution::AbstractExecPayload, + data::{Blob, PartialDataColumnHeader}, fork::ForkName, kzg_ext::KzgProofs, state::BeaconStateError, @@ -140,33 +140,29 @@ impl<E: EthSpec> BlobSidecar<E> { }) } - pub fn new_with_existing_proof<Payload: AbstractExecPayload<E>>( + pub fn new_with_existing_proof<T: TryInto<PartialDataColumnHeader<E>>>( index: usize, blob: Blob<E>, - signed_block: &SignedBeaconBlock<E, Payload>, - signed_block_header: SignedBeaconBlockHeader, - kzg_commitments_inclusion_proof: &[Hash256], + header: T, kzg_proof: KzgProof, ) -> Result<Self, BlobSidecarError> { - let expected_kzg_commitments = signed_block - .message() - .body() - .blob_kzg_commitments() - .map_err(|_e| BlobSidecarError::PreDeneb)?; - let kzg_commitment = *expected_kzg_commitments + let header = header.try_into().map_err(|_| BlobSidecarError::PreDeneb)?; + let kzg_commitment = *header + .kzg_commitments .get(index) .ok_or(BlobSidecarError::MissingKzgCommitment)?; - let kzg_commitment_inclusion_proof = signed_block - .message() - .body() - .complete_kzg_commitment_merkle_proof(index, kzg_commitments_inclusion_proof)?; + let kzg_commitment_inclusion_proof = complete_kzg_commitment_merkle_proof::<E>( + &header.kzg_commitments, + index, + &header.kzg_commitments_inclusion_proof, + )?; Ok(Self { index: index as u64, blob, kzg_commitment, kzg_proof, - signed_block_header, + signed_block_header: header.signed_block_header, kzg_commitment_inclusion_proof, }) } @@ -253,14 +249,17 @@ impl<E: EthSpec> BlobSidecar<E> { let blob = Blob::<E>::new(blob_bytes) .map_err(|e| format!("error constructing random blob: {:?}", e))?; - let kzg_blob = KzgBlob::from_bytes(&blob).unwrap(); + let kzg_blob: &[u8; BYTES_PER_BLOB] = blob + .as_ref() + .try_into() + .map_err(|e| format!("error converting blob to kzg blob ref: {:?}", e))?; let commitment = kzg - .blob_to_kzg_commitment(&kzg_blob) + .blob_to_kzg_commitment(kzg_blob) .map_err(|e| format!("error computing kzg commitment: {:?}", e))?; let proof = kzg - .compute_blob_kzg_proof(&kzg_blob, commitment) + .compute_blob_kzg_proof(kzg_blob, commitment) .map_err(|e| format!("error computing kzg proof: {:?}", e))?; Ok(Self { @@ -300,3 +299,39 @@ pub type BlobSidecarList<E> = RuntimeVariableList<Arc<BlobSidecar<E>>>; /// Alias for a non length-constrained list of `BlobSidecar`s. pub type FixedBlobSidecarList<E> = RuntimeFixedVector<Option<Arc<BlobSidecar<E>>>>; pub type BlobsList<E> = VariableList<Blob<E>, <E as EthSpec>::MaxBlobCommitmentsPerBlock>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::max_blobs_by_root_request_common; + use fixed_bytes::FixedBytesExtended; + + // This is the "correct" implementation of max_blobs_by_root_request. + // This test ensures that the simplified implementation doesn't deviate from it. + fn max_blobs_by_root_request_implementation(max_request_blob_sidecars: u64) -> usize { + let max_request_blob_sidecars = max_request_blob_sidecars as usize; + let empty_blob_identifier = BlobIdentifier { + block_root: Hash256::zero(), + index: 0, + }; + + RuntimeVariableList::<BlobIdentifier>::new( + vec![empty_blob_identifier; max_request_blob_sidecars], + max_request_blob_sidecars, + ) + .expect("creating a RuntimeVariableList of size `max_request_blob_sidecars` should succeed") + .as_ssz_bytes() + .len() + } + + #[test] + fn max_blobs_by_root_request_matches_simplified() { + for n in [0, 1, 2, 8, 16, 32, 64, 128, 256, 512, 768, 1024, 1152] { + assert_eq!( + max_blobs_by_root_request_common(n), + max_blobs_by_root_request_implementation(n), + "Mismatch at n={n}" + ); + } + } +} diff --git a/consensus/types/src/data/data_column_sidecar.rs b/consensus/types/src/data/data_column_sidecar.rs index 71d821f83e..a653ee338c 100644 --- a/consensus/types/src/data/data_column_sidecar.rs +++ b/consensus/types/src/data/data_column_sidecar.rs @@ -7,10 +7,11 @@ use kzg::{KzgCommitment, KzgProof}; use merkle_proof::verify_merkle_proof; use safe_arith::ArithError; use serde::{Deserialize, Serialize}; -use ssz::Encode; +use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use ssz_types::Error as SszError; use ssz_types::{FixedVector, VariableList}; +use superstruct::superstruct; use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -18,6 +19,10 @@ use tree_hash_derive::TreeHash; use crate::{ block::{BLOB_KZG_COMMITMENTS_INDEX, BeaconBlockHeader, SignedBeaconBlockHeader}, core::{Epoch, EthSpec, Hash256, Slot}, + data::{ + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnSidecar, + PartialDataColumnSidecarError, PartialDataColumnSidecarRef, + }, fork::ForkName, kzg_ext::{KzgCommitments, KzgError}, state::BeaconStateError, @@ -38,37 +43,154 @@ pub struct DataColumnsByRootIdentifier<E: EthSpec> { pub type DataColumnSidecarList<E> = Vec<Arc<DataColumnSidecar<E>>>; +#[superstruct( + variants(Fulu, Gloas), + variant_attributes( + derive( + Debug, + Clone, + Serialize, + Deserialize, + Decode, + Encode, + TestRandom, + Educe, + TreeHash, + ), + context_deserialize(ForkName), + educe(PartialEq, Hash(bound(E: EthSpec))), + serde(bound = "E: EthSpec", deny_unknown_fields), + cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") + ) + ), + ref_attributes(derive(TreeHash), tree_hash(enum_behaviour = "transparent")), + cast_error(ty = "DataColumnSidecarError", expr = "DataColumnSidecarError::IncorrectStateVariant"), + partial_getter_error(ty = "DataColumnSidecarError", expr = "DataColumnSidecarError::IncorrectStateVariant") +)] #[cfg_attr( feature = "arbitrary", derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, Educe)] -#[serde(bound = "E: EthSpec")] -#[educe(PartialEq, Eq, Hash(bound(E: EthSpec)))] -#[context_deserialize(ForkName)] +#[derive(Debug, Clone, Serialize, TreeHash, Encode, Educe, Deserialize)] +#[educe(PartialEq, Hash(bound(E: EthSpec)))] +#[serde(bound = "E: EthSpec", untagged, deny_unknown_fields)] +#[tree_hash(enum_behaviour = "transparent")] +#[ssz(enum_behaviour = "transparent")] pub struct DataColumnSidecar<E: EthSpec> { #[serde(with = "serde_utils::quoted_u64")] pub index: ColumnIndex, #[serde(with = "ssz_types::serde_utils::list_of_hex_fixed_vec")] pub column: DataColumn<E>, - /// All the KZG commitments and proofs associated with the block, used for verifying sample cells. + /// All the KZG commitments associated with the block, used for verifying sample cells. + /// In Gloas, commitments come from `block.body.signed_execution_payload_bid.message.blob_kzg_commitments`. + #[superstruct(only(Fulu))] pub kzg_commitments: KzgCommitments<E>, pub kzg_proofs: VariableList<KzgProof, E::MaxBlobCommitmentsPerBlock>, + #[superstruct(only(Fulu))] pub signed_block_header: SignedBeaconBlockHeader, /// An inclusion proof, proving the inclusion of `blob_kzg_commitments` in `BeaconBlockBody`. + #[superstruct(only(Fulu))] pub kzg_commitments_inclusion_proof: FixedVector<Hash256, E::KzgCommitmentsInclusionProofDepth>, + #[superstruct(only(Gloas), partial_getter(rename = "slot_gloas"))] + pub slot: Slot, + #[superstruct(only(Gloas))] + pub beacon_block_root: Hash256, } impl<E: EthSpec> DataColumnSidecar<E> { pub fn slot(&self) -> Slot { - self.signed_block_header.message.slot + match self { + DataColumnSidecar::Fulu(column) => column.slot(), + DataColumnSidecar::Gloas(column) => column.slot, + } } pub fn epoch(&self) -> Epoch { self.slot().epoch(E::slots_per_epoch()) } + pub fn block_root(&self) -> Hash256 { + match self { + DataColumnSidecar::Fulu(column) => column.block_root(), + DataColumnSidecar::Gloas(column) => column.beacon_block_root, + } + } + + /// Custom SSZ decoder that takes a `ForkName` as context. + pub fn from_ssz_bytes_for_fork( + bytes: &[u8], + fork_name: ForkName, + ) -> Result<Self, ssz::DecodeError> { + match fork_name { + ForkName::Base + | ForkName::Altair + | ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Electra => Err(ssz::DecodeError::NoMatchingVariant), + ForkName::Eip7805 => Err(ssz::DecodeError::NoMatchingVariant), + ForkName::Fulu => Ok(DataColumnSidecar::Fulu( + DataColumnSidecarFulu::from_ssz_bytes(bytes)?, + )), + ForkName::Gloas => Ok(DataColumnSidecar::Gloas( + DataColumnSidecarGloas::from_ssz_bytes(bytes)?, + )), + } + } + + /// Convert this full data column into a partial data column reference for KZG verification. + /// The header will NOT be set. + /// + /// Uses the supplied filter to determine which cells to include in the partial sidecar. + pub fn try_filter_to_partial_ref<F, Err>( + &self, + filter: F, + ) -> Result<Option<PartialDataColumnSidecarRef<'_, E>>, Err> + where + F: Fn(usize, &Cell<E>, &KzgProof) -> Result<bool, Err>, + Err: From<PartialDataColumnSidecarError>, + { + let len = self.column().len(); + let mut new_bitmap = CellBitmap::<E>::with_capacity(len) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + let mut new_column = Vec::with_capacity(len); + let mut new_proofs = Vec::with_capacity(len); + let iter = self.column().iter().zip(self.kzg_proofs().iter()); + + for (blob_idx, (cell, proof)) in iter.enumerate() { + if filter(blob_idx, cell, proof)? { + // Keep this cell + new_column.push(cell); + new_proofs.push(proof); + // Mark as present + new_bitmap + .set(blob_idx, true) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + } + } + + if new_column.is_empty() { + return Ok(None); + } + + Ok(Some(PartialDataColumnSidecarRef { + cells_present_bitmap: new_bitmap, + column: new_column, + kzg_proofs: new_proofs, + header: None.into(), + })) + } +} + +impl<E: EthSpec> DataColumnSidecarFulu<E> { + pub fn slot(&self) -> Slot { + self.signed_block_header.message.slot + } + pub fn block_root(&self) -> Hash256 { self.signed_block_header.message.tree_hash_root() } @@ -130,6 +252,63 @@ impl<E: EthSpec> DataColumnSidecar<E> { .as_ssz_bytes() .len() } + + /// Convert this full data column into a verifiable partial data column. + pub fn to_partial(&self) -> PartialDataColumn<E> { + let cell_count = self.column.len(); + let mut bitmap = + CellBitmap::<E>::with_capacity(cell_count).expect("our column has the same bound"); + for idx in 0..cell_count { + bitmap + .set(idx, true) + .expect("The correct size is initialized right above"); + } + + let block_root = self.block_root(); + + PartialDataColumn { + block_root, + index: self.index, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: self.column.clone(), + kzg_proofs: self.kzg_proofs.clone(), + header: Some(PartialDataColumnHeader { + kzg_commitments: self.kzg_commitments.clone(), + signed_block_header: self.signed_block_header.clone(), + kzg_commitments_inclusion_proof: self.kzg_commitments_inclusion_proof.clone(), + }) + .into(), + }, + } + } +} + +impl<E: EthSpec> DataColumnSidecarGloas<E> { + pub fn min_size() -> usize { + // min size is one cell + Self { + index: 0, + column: VariableList::new(vec![Cell::<E>::default()]).unwrap(), + kzg_proofs: VariableList::new(vec![KzgProof::empty()]).unwrap(), + slot: Slot::new(0), + beacon_block_root: Hash256::ZERO, + } + .as_ssz_bytes() + .len() + } + + pub fn max_size(max_blobs_per_block: usize) -> usize { + Self { + index: 0, + column: VariableList::new(vec![Cell::<E>::default(); max_blobs_per_block]).unwrap(), + kzg_proofs: VariableList::new(vec![KzgProof::empty(); max_blobs_per_block]).unwrap(), + slot: Slot::new(0), + beacon_block_root: Hash256::ZERO, + } + .as_ssz_bytes() + .len() + } } #[derive(Debug)] @@ -145,6 +324,7 @@ pub enum DataColumnSidecarError { SszError(SszError), BuildSidecarFailed(String), InvalidCellProofLength { expected: usize, actual: usize }, + IncorrectStateVariant, } impl From<ArithError> for DataColumnSidecarError { @@ -170,3 +350,43 @@ impl From<SszError> for DataColumnSidecarError { Self::SszError(e) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{MainnetEthSpec, max_data_columns_by_root_request_common}; + use fixed_bytes::FixedBytesExtended; + use ssz_types::RuntimeVariableList; + + // This is the "correct" implementation of max_data_columns_by_root_request. + // This test ensures that the simplified implementation doesn't deviate from it. + fn max_data_columns_by_root_request_implementation<E: EthSpec>( + max_request_blocks: u64, + ) -> usize { + let max_request_blocks = max_request_blocks as usize; + + let empty_data_columns_by_root_id = DataColumnsByRootIdentifier { + block_root: Hash256::zero(), + columns: VariableList::repeat_full(0), + }; + + RuntimeVariableList::<DataColumnsByRootIdentifier<E>>::new( + vec![empty_data_columns_by_root_id; max_request_blocks], + max_request_blocks, + ) + .expect("creating a RuntimeVariableList of size `max_request_blocks` should succeed") + .as_ssz_bytes() + .len() + } + + #[test] + fn max_data_columns_by_root_request_matches_simplified() { + for n in [0, 1, 2, 8, 16, 32, 64, 128, 256, 512, 1024] { + assert_eq!( + max_data_columns_by_root_request_common::<MainnetEthSpec>(n), + max_data_columns_by_root_request_implementation::<MainnetEthSpec>(n), + "Mismatch at n={n}" + ); + } + } +} diff --git a/consensus/types/src/data/data_column_subnet_id.rs b/consensus/types/src/data/data_column_subnet_id.rs index c30ebbba20..0a05cd8936 100644 --- a/consensus/types/src/data/data_column_subnet_id.rs +++ b/consensus/types/src/data/data_column_subnet_id.rs @@ -72,3 +72,9 @@ impl From<&DataColumnSubnetId> for u64 { val.0 } } + +pub fn all_data_column_sidecar_subnets_from_spec( + spec: &ChainSpec, +) -> impl Iterator<Item = DataColumnSubnetId> { + (0..spec.data_column_sidecar_subnet_count).map(DataColumnSubnetId::new) +} diff --git a/consensus/types/src/data/mod.rs b/consensus/types/src/data/mod.rs index 10d062bada..9c7eb42626 100644 --- a/consensus/types/src/data/mod.rs +++ b/consensus/types/src/data/mod.rs @@ -2,6 +2,7 @@ mod blob_sidecar; mod data_column_custody_group; mod data_column_sidecar; mod data_column_subnet_id; +mod partial_data_column_sidecar; pub use blob_sidecar::{ BlobIdentifier, BlobSidecar, BlobSidecarError, BlobSidecarList, BlobsList, FixedBlobSidecarList, @@ -13,9 +14,14 @@ pub use data_column_custody_group::{ }; pub use data_column_sidecar::{ Cell, ColumnIndex, DataColumn, DataColumnSidecar, DataColumnSidecarError, - DataColumnSidecarList, DataColumnsByRootIdentifier, + DataColumnSidecarFulu, DataColumnSidecarGloas, DataColumnSidecarList, + DataColumnsByRootIdentifier, +}; +pub use data_column_subnet_id::{DataColumnSubnetId, all_data_column_sidecar_subnets_from_spec}; +pub use partial_data_column_sidecar::{ + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + PartialDataColumnSidecar, PartialDataColumnSidecarError, PartialDataColumnSidecarRef, }; -pub use data_column_subnet_id::DataColumnSubnetId; use crate::core::EthSpec; use ssz_types::FixedVector; diff --git a/consensus/types/src/data/partial_data_column_sidecar.rs b/consensus/types/src/data/partial_data_column_sidecar.rs new file mode 100644 index 0000000000..df65be1ae3 --- /dev/null +++ b/consensus/types/src/data/partial_data_column_sidecar.rs @@ -0,0 +1,429 @@ +use crate::{ + block::{BLOB_KZG_COMMITMENTS_INDEX, SignedBeaconBlock, SignedBeaconBlockHeader}, + core::{EthSpec, Hash256, Slot}, + data::{Cell, ColumnIndex, DataColumnSidecar, DataColumnSidecarFulu}, + execution::AbstractExecPayload, + kzg_ext::KzgCommitments, + state::BeaconStateError, + test_utils::TestRandom, +}; +use educe::Educe; +use kzg::KzgProof; +use merkle_proof::verify_merkle_proof; +use ssz::BitList; +use ssz_derive::{Decode, Encode}; +use ssz_types::{FixedVector, ListEncodedOption, VariableList}; +use std::fmt::Display; +use test_random_derive::TestRandom; +use tree_hash::TreeHash; +use tree_hash_derive::TreeHash; + +pub type CellBitmap<E> = BitList<<E as EthSpec>::MaxBlobCommitmentsPerBlock>; + +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Encode, Decode, TreeHash, Educe)] +#[educe(PartialEq, Eq, Hash(bound = "E: EthSpec"))] +pub struct PartialDataColumnSidecar<E: EthSpec> { + pub cells_present_bitmap: CellBitmap<E>, + pub column: VariableList<Cell<E>, E::MaxBlobCommitmentsPerBlock>, + pub kzg_proofs: VariableList<KzgProof, E::MaxBlobCommitmentsPerBlock>, + pub header: ListEncodedOption<PartialDataColumnHeader<E>>, +} + +/// Equivalent to `PartialDataColumnSidecar`, but containing references to the cells. This is done +/// so that we can get a part of a sidecar without expensively cloning all the contents. +#[derive(Debug, Clone, Encode)] +pub struct PartialDataColumnSidecarRef<'a, E: EthSpec> { + pub cells_present_bitmap: CellBitmap<E>, + // It is fine to use `Vec` here as we never decode directly into this type, and only create + // this from the `PartialDataColumnSidecar` type above. This avoids a few ugly `expect` calls. + pub column: Vec<&'a Cell<E>>, + pub kzg_proofs: Vec<&'a KzgProof>, + pub header: ListEncodedOption<&'a PartialDataColumnHeader<E>>, +} + +#[derive(Debug, Clone, Copy)] +pub enum PartialDataColumnSidecarError { + UnexpectedBounds, + InternallyInconsistent, + DifferingLengths { lhs_len: usize, rhs_len: usize }, + ConflictingData, +} + +impl<E: EthSpec> PartialDataColumnSidecar<E> { + pub fn is_complete(&self) -> bool { + self.cells_present_bitmap.num_set_bits() == self.cells_present_bitmap.len() + } + + pub fn get(&self, idx: usize) -> Option<(&Cell<E>, &KzgProof)> { + if !self.cells_present_bitmap.get(idx).unwrap_or(false) { + return None; + } + let storage_idx = self + .cells_present_bitmap + .iter() + .take(idx) + .filter(|b| *b) + .count(); + self.column + .get(storage_idx) + .and_then(|cell| self.kzg_proofs.get(storage_idx).map(|proof| (cell, proof))) + } + + /// Creates a reference to this sidecar containing only the blob indices for which the passed + /// closure returns `true` and is present in `self`. Will return `None` if there is no overlap. + pub fn filter<F>( + &self, + filter: F, + ) -> Result<Option<PartialDataColumnSidecarRef<'_, E>>, PartialDataColumnSidecarError> + where + F: Fn(usize) -> bool, + { + let len = self.verify_len()?; + + let mut new_bitmap = self.cells_present_bitmap.clone(); + let mut new_column = Vec::with_capacity(len); + let mut new_proofs = Vec::with_capacity(len); + let mut iter = self.column.iter().zip(self.kzg_proofs.iter()); + + for (blob_idx, present) in self.cells_present_bitmap.iter().enumerate() { + if present { + let (cell, proof) = iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + if filter(blob_idx) { + // Keep this cell + new_column.push(cell); + new_proofs.push(proof); + } else { + // Mark as not present + new_bitmap + .set(blob_idx, false) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + } + } + } + + if new_column.is_empty() { + return Ok(None); + } + + Ok(Some(PartialDataColumnSidecarRef { + cells_present_bitmap: new_bitmap, + column: new_column, + kzg_proofs: new_proofs, + header: self.header.as_ref().into(), + })) + } + + pub fn verify_len(&self) -> Result<usize, PartialDataColumnSidecarError> { + let len = self.cells_present_bitmap.num_set_bits(); + if len != self.kzg_proofs.len() || len != self.column.len() { + return Err(PartialDataColumnSidecarError::InternallyInconsistent); + } + Ok(len) + } +} + +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Encode, Decode, TreeHash, TestRandom, Educe)] +#[educe(PartialEq, Eq, Hash(bound = "E: EthSpec"))] +pub struct PartialDataColumnHeader<E: EthSpec> { + pub kzg_commitments: KzgCommitments<E>, + pub signed_block_header: SignedBeaconBlockHeader, + pub kzg_commitments_inclusion_proof: FixedVector<Hash256, E::KzgCommitmentsInclusionProofDepth>, +} + +impl<E: EthSpec> PartialDataColumnHeader<E> { + pub fn slot(&self) -> Slot { + self.signed_block_header.message.slot + } + + pub fn verify_inclusion_proof(&self) -> bool { + let blob_kzg_commitments_root = self.kzg_commitments.tree_hash_root(); + + verify_merkle_proof( + blob_kzg_commitments_root, + &self.kzg_commitments_inclusion_proof, + E::kzg_commitments_inclusion_proof_depth(), + BLOB_KZG_COMMITMENTS_INDEX, + self.signed_block_header.message.body_root, + ) + } +} + +impl<E: EthSpec, P: AbstractExecPayload<E>> TryFrom<&SignedBeaconBlock<E, P>> + for PartialDataColumnHeader<E> +{ + type Error = BeaconStateError; + + fn try_from(block: &SignedBeaconBlock<E, P>) -> Result<Self, Self::Error> { + Ok(Self { + kzg_commitments: block.message().body().blob_kzg_commitments()?.clone(), + signed_block_header: block.signed_block_header(), + kzg_commitments_inclusion_proof: block + .message() + .body() + .kzg_commitments_merkle_proof()?, + }) + } +} + +#[derive(Debug, Clone, Encode, Decode, PartialEq, Eq)] +pub struct PartialDataColumnPartsMetadata<E: EthSpec> { + pub available: CellBitmap<E>, + pub requests: CellBitmap<E>, +} + +impl<E: EthSpec> Display for PartialDataColumnPartsMetadata<E> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "(available: {}, requested: {})", + self.available, self.requests + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PartialDataColumn<E: EthSpec> { + pub block_root: Hash256, + pub index: ColumnIndex, + pub sidecar: PartialDataColumnSidecar<E>, +} + +impl<E: EthSpec> PartialDataColumn<E> { + /// Equivalent to a call to `clone` followed by `try_into_full`, but returns early if conversion + /// is not possible. + pub fn try_clone_full( + &self, + header: &PartialDataColumnHeader<E>, + ) -> Option<DataColumnSidecar<E>> { + if !self.sidecar.is_complete() { + return None; + } + + Some(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: self.index, + column: self.sidecar.column.clone(), + kzg_commitments: header.kzg_commitments.clone(), + kzg_proofs: self.sidecar.kzg_proofs.clone(), + signed_block_header: header.signed_block_header.clone(), + kzg_commitments_inclusion_proof: header.kzg_commitments_inclusion_proof.clone(), + })) + } + + pub fn try_into_full( + self, + header: &PartialDataColumnHeader<E>, + ) -> Option<DataColumnSidecar<E>> { + if !self.sidecar.is_complete() { + return None; + } + + Some(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: self.index, + column: self.sidecar.column, + kzg_commitments: header.kzg_commitments.clone(), + kzg_proofs: self.sidecar.kzg_proofs, + signed_block_header: header.signed_block_header.clone(), + kzg_commitments_inclusion_proof: header.kzg_commitments_inclusion_proof.clone(), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MinimalEthSpec; + use bls::Signature; + use fixed_bytes::FixedBytesExtended; + use kzg::KzgCommitment; + use ssz::Encode; + + type E = MinimalEthSpec; + + fn make_cell(marker: u8) -> Cell<E> { + let mut cell = Cell::<E>::default(); + cell[0] = marker; + cell + } + + fn make_sidecar_with_marker( + total_blobs: usize, + present_indices: &[usize], + marker_base: u8, + ) -> PartialDataColumnSidecar<E> { + let mut bitmap = CellBitmap::<E>::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + let column: VariableList<_, _> = present_indices + .iter() + .map(|&idx| make_cell(marker_base.wrapping_add(idx as u8))) + .collect::<Vec<_>>() + .try_into() + .unwrap(); + let proofs: VariableList<_, _> = present_indices + .iter() + .map(|_| KzgProof::empty()) + .collect::<Vec<_>>() + .try_into() + .unwrap(); + + PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column, + kzg_proofs: proofs, + header: None.into(), + } + } + + fn make_sidecar(total_blobs: usize, present_indices: &[usize]) -> PartialDataColumnSidecar<E> { + make_sidecar_with_marker(total_blobs, present_indices, 0) + } + + fn make_header(num_commitments: usize) -> PartialDataColumnHeader<E> { + PartialDataColumnHeader { + kzg_commitments: vec![KzgCommitment([0u8; 48]); num_commitments] + .try_into() + .unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: crate::BeaconBlockHeader { + slot: Slot::new(0), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + } + } + + // -- filter tests -- + + #[test] + fn filter_keeps_matching_cells() { + let sidecar = make_sidecar(6, &[0, 2, 4]); + let filtered = sidecar.filter(|idx| idx == 0 || idx == 4).unwrap().unwrap(); + assert_eq!(filtered.column.len(), 2); + assert_eq!(filtered.kzg_proofs.len(), 2); + assert!(filtered.cells_present_bitmap.get(0).unwrap()); + assert!(!filtered.cells_present_bitmap.get(2).unwrap()); + assert!(filtered.cells_present_bitmap.get(4).unwrap()); + } + + #[test] + fn filter_returns_none_when_no_overlap() { + let sidecar = make_sidecar(6, &[0, 2, 4]); + assert!( + sidecar + .filter(|idx| idx == 1 || idx == 3) + .unwrap() + .is_none() + ); + } + + #[test] + fn filter_preserves_all_when_all_match() { + let sidecar = make_sidecar(6, &[0, 2, 4]); + let filtered = sidecar.filter(|_| true).unwrap().unwrap(); + assert_eq!(filtered.column.len(), 3); + assert_eq!(filtered.kzg_proofs.len(), 3); + assert_eq!(filtered.cells_present_bitmap, sidecar.cells_present_bitmap); + + // Also, check that the encoded version matches + assert_eq!(filtered.as_ssz_bytes(), sidecar.as_ssz_bytes()); + } + + // -- is_complete tests -- + + #[test] + fn is_complete_true_when_all_bits_set() { + let sidecar = make_sidecar(4, &[0, 1, 2, 3]); + assert!(sidecar.is_complete()); + } + + #[test] + fn is_complete_false_when_partial() { + let sidecar = make_sidecar(4, &[0, 2]); + assert!(!sidecar.is_complete()); + } + + // -- try_clone_full tests (on PartialDataColumn) -- + + #[test] + fn try_clone_full_succeeds_when_complete() { + let sidecar = make_sidecar(3, &[0, 1, 2]); + let header = make_header(3); + let partial = PartialDataColumn { + block_root: Hash256::zero(), + index: 5, + sidecar, + }; + let full = partial.try_clone_full(&header).unwrap(); + assert_eq!(*full.index(), 5); + assert_eq!(full.column().len(), 3); + } + + #[test] + fn try_clone_full_returns_none_when_incomplete() { + let sidecar = make_sidecar(4, &[0, 2]); + let header = make_header(4); + let partial = PartialDataColumn { + block_root: Hash256::zero(), + index: 0, + sidecar, + }; + assert!(partial.try_clone_full(&header).is_none()); + } + + // -- get tests -- + + #[test] + fn get_sparse_bitmap_maps_to_correct_storage_position() { + // bitmap: [false, true, false, true] → column: [cell_1, cell_3] + let sidecar = make_sidecar_with_marker(4, &[1, 3], 0); + let (cell, _) = sidecar.get(1).expect("cell at blob index 1 should exist"); + assert_eq!(cell[0], 1); + let (cell, _) = sidecar.get(3).expect("cell at blob index 3 should exist"); + assert_eq!(cell[0], 3); + } + + #[test] + fn get_absent_blob_index_returns_none() { + let sidecar = make_sidecar(4, &[1, 3]); + assert!(sidecar.get(0).is_none()); + assert!(sidecar.get(2).is_none()); + } + + #[test] + fn get_out_of_range_returns_none() { + let sidecar = make_sidecar(4, &[0, 2]); + assert!(sidecar.get(4).is_none()); + assert!(sidecar.get(100).is_none()); + } + + #[test] + fn get_dense_bitmap_matches_direct_index() { + let sidecar = make_sidecar_with_marker(4, &[0, 1, 2, 3], 10); + for i in 0..4 { + let (cell, _) = sidecar.get(i).expect("all cells should be present"); + assert_eq!(cell[0], 10 + i as u8); + } + } +} diff --git a/consensus/types/src/execution/execution_payload.rs b/consensus/types/src/execution/execution_payload.rs index 00f8e036cf..0d8a3e8f25 100644 --- a/consensus/types/src/execution/execution_payload.rs +++ b/consensus/types/src/execution/execution_payload.rs @@ -10,8 +10,7 @@ use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ - core::{Address, EthSpec, Hash256}, - execution::ExecutionBlockHash, + core::{Address, EthSpec, ExecutionBlockHash, Hash256, Slot}, fork::{ForkName, ForkVersionDecode}, state::BeaconStateError, test_utils::TestRandom, @@ -110,6 +109,12 @@ pub struct ExecutionPayload<E: EthSpec> { #[superstruct(only(Deneb, Electra, Fulu, Eip7805, Gloas), partial_getter(copy))] #[serde(with = "serde_utils::quoted_u64")] pub excess_blob_gas: u64, + /// EIP-7928: Block access list + #[superstruct(only(Gloas))] + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub block_access_list: VariableList<u8, E::MaxBytesPerTransaction>, + #[superstruct(only(Gloas), partial_getter(copy))] + pub slot_number: Slot, } impl<'a, E: EthSpec> ExecutionPayloadRef<'a, E> { diff --git a/consensus/types/src/execution/execution_payload_bid.rs b/consensus/types/src/execution/execution_payload_bid.rs index 20e461334d..b2438681c1 100644 --- a/consensus/types/src/execution/execution_payload_bid.rs +++ b/consensus/types/src/execution/execution_payload_bid.rs @@ -1,5 +1,6 @@ +use crate::kzg_ext::KzgCommitments; use crate::test_utils::TestRandom; -use crate::{Address, ExecutionBlockHash, ForkName, Hash256, SignedRoot, Slot}; +use crate::{Address, EthSpec, ExecutionBlockHash, ForkName, Hash256, SignedRoot, Slot}; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; @@ -10,14 +11,20 @@ use tree_hash_derive::TreeHash; #[derive( Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe, TestRandom, )] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] #[educe(PartialEq, Hash)] +#[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#executionpayloadbid -pub struct ExecutionPayloadBid { +pub struct ExecutionPayloadBid<E: EthSpec> { pub parent_block_hash: ExecutionBlockHash, pub parent_block_root: Hash256, pub block_hash: ExecutionBlockHash, + pub prev_randao: Hash256, #[serde(with = "serde_utils::address_hex")] pub fee_recipient: Address, #[serde(with = "serde_utils::quoted_u64")] @@ -27,14 +34,18 @@ pub struct ExecutionPayloadBid { pub slot: Slot, #[serde(with = "serde_utils::quoted_u64")] pub value: u64, - pub blob_kzg_commitments_root: Hash256, + #[serde(with = "serde_utils::quoted_u64")] + pub execution_payment: u64, + pub blob_kzg_commitments: KzgCommitments<E>, + pub execution_requests_root: Hash256, } -impl SignedRoot for ExecutionPayloadBid {} +impl<E: EthSpec> SignedRoot for ExecutionPayloadBid<E> {} #[cfg(test)] mod tests { use super::*; + use crate::MainnetEthSpec; - ssz_and_tree_hash_tests!(ExecutionPayloadBid); + ssz_and_tree_hash_tests!(ExecutionPayloadBid<MainnetEthSpec>); } diff --git a/consensus/types/src/execution/execution_payload_envelope.rs b/consensus/types/src/execution/execution_payload_envelope.rs index 64e03cec5a..a6d123bd21 100644 --- a/consensus/types/src/execution/execution_payload_envelope.rs +++ b/consensus/types/src/execution/execution_payload_envelope.rs @@ -1,11 +1,11 @@ +use crate::execution::{ExecutionPayloadGloas, ExecutionRequests}; use crate::test_utils::TestRandom; -use crate::{ - EthSpec, ExecutionPayloadGloas, ExecutionRequests, ForkName, Hash256, KzgCommitments, - SignedRoot, Slot, -}; +use crate::{EthSpec, ForkName, Hash256, SignedRoot, Slot}; use context_deserialize::context_deserialize; use educe::Educe; +use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::{BYTES_PER_LENGTH_OFFSET, Encode as SszEncode}; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -20,9 +20,48 @@ pub struct ExecutionPayloadEnvelope<E: EthSpec> { #[serde(with = "serde_utils::quoted_u64")] pub builder_index: u64, pub beacon_block_root: Hash256, - pub slot: Slot, - pub blob_kzg_commitments: KzgCommitments<E>, - pub state_root: Hash256, + pub parent_beacon_block_root: Hash256, +} + +impl<E: EthSpec> ExecutionPayloadEnvelope<E> { + /// Returns an empty envelope with all fields zeroed. Used for SSZ size calculations. + pub fn empty() -> Self { + Self { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: Hash256::zero(), + parent_beacon_block_root: Hash256::zero(), + } + } + + /// Returns the minimum SSZ-encoded size (all variable-length fields empty). + pub fn min_size() -> usize { + Self::empty().as_ssz_bytes().len() + } + + /// Returns the maximum SSZ-encoded size. + #[allow(clippy::arithmetic_side_effects)] + pub fn max_size() -> usize { + Self::min_size() + // ExecutionPayloadGloas variable-length fields: + + (E::max_extra_data_bytes() * <u8 as SszEncode>::ssz_fixed_len()) + + (E::max_transactions_per_payload() + * (BYTES_PER_LENGTH_OFFSET + E::max_bytes_per_transaction())) + + (E::max_withdrawals_per_payload() + * <crate::Withdrawal as SszEncode>::ssz_fixed_len()) + // ExecutionRequests variable-length fields: + + (E::max_deposit_requests_per_payload() + * <crate::DepositRequest as SszEncode>::ssz_fixed_len()) + + (E::max_withdrawal_requests_per_payload() + * <crate::WithdrawalRequest as SszEncode>::ssz_fixed_len()) + + (E::max_consolidation_requests_per_payload() + * <crate::ConsolidationRequest as SszEncode>::ssz_fixed_len()) + } + + pub fn slot(&self) -> Slot { + self.payload.slot_number + } } impl<E: EthSpec> SignedRoot for ExecutionPayloadEnvelope<E> {} diff --git a/consensus/types/src/execution/execution_payload_header.rs b/consensus/types/src/execution/execution_payload_header.rs index 02f3afa709..052f16e57d 100644 --- a/consensus/types/src/execution/execution_payload_header.rs +++ b/consensus/types/src/execution/execution_payload_header.rs @@ -11,11 +11,11 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ - core::{Address, EthSpec, Hash256, Uint256}, + core::{Address, EthSpec, ExecutionBlockHash, Hash256, Uint256}, execution::{ - ExecutionBlockHash, ExecutionPayloadBellatrix, ExecutionPayloadCapella, - ExecutionPayloadDeneb, ExecutionPayloadEip7805, ExecutionPayloadElectra, - ExecutionPayloadFulu, ExecutionPayloadRef, Transactions, + ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, + ExecutionPayloadEip7805, ExecutionPayloadElectra, ExecutionPayloadFulu, + ExecutionPayloadRef, Transactions, }, fork::ForkName, map_execution_payload_ref_into_execution_payload_header, diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index eac4291e77..bd30dcb19b 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -1,5 +1,4 @@ mod eth1_data; -mod execution_block_hash; mod execution_block_header; #[macro_use] mod execution_payload; @@ -17,7 +16,6 @@ mod signed_execution_payload_envelope; pub use bls_to_execution_change::BlsToExecutionChange; pub use eth1_data::Eth1Data; -pub use execution_block_hash::ExecutionBlockHash; pub use execution_block_header::{EncodableExecutionBlockHeader, ExecutionBlockHeader}; pub use execution_payload::{ ExecutionPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, diff --git a/consensus/types/src/execution/payload.rs b/consensus/types/src/execution/payload.rs index 784bc76277..b3dc96366f 100644 --- a/consensus/types/src/execution/payload.rs +++ b/consensus/types/src/execution/payload.rs @@ -11,9 +11,9 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ - core::{Address, EthSpec, Hash256}, + core::{Address, EthSpec, ExecutionBlockHash, Hash256}, execution::{ - ExecutionBlockHash, ExecutionPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, + ExecutionPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, ExecutionPayloadEip7805, ExecutionPayloadElectra, ExecutionPayloadFulu, ExecutionPayloadHeader, ExecutionPayloadHeaderBellatrix, ExecutionPayloadHeaderCapella, ExecutionPayloadHeaderDeneb, ExecutionPayloadHeaderEip7805, diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 29dfd03ba0..48da445332 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -1,5 +1,6 @@ +use crate::execution::ExecutionPayloadBid; use crate::test_utils::TestRandom; -use crate::{ExecutionPayloadBid, ForkName}; +use crate::{EthSpec, ForkName}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; @@ -9,16 +10,21 @@ use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; #[derive(TestRandom, TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] #[educe(PartialEq, Hash)] +#[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/beacon-chain.md#signedexecutionpayloadbid -pub struct SignedExecutionPayloadBid { - pub message: ExecutionPayloadBid, +pub struct SignedExecutionPayloadBid<E: EthSpec> { + pub message: ExecutionPayloadBid<E>, pub signature: Signature, } -impl SignedExecutionPayloadBid { +impl<E: EthSpec> SignedExecutionPayloadBid<E> { pub fn empty() -> Self { Self { message: ExecutionPayloadBid::default(), @@ -30,6 +36,7 @@ impl SignedExecutionPayloadBid { #[cfg(test)] mod tests { use super::*; + use crate::MainnetEthSpec; - ssz_and_tree_hash_tests!(SignedExecutionPayloadBid); + ssz_and_tree_hash_tests!(SignedExecutionPayloadBid<MainnetEthSpec>); } diff --git a/consensus/types/src/execution/signed_execution_payload_envelope.rs b/consensus/types/src/execution/signed_execution_payload_envelope.rs index 1641041615..522c8b3f54 100644 --- a/consensus/types/src/execution/signed_execution_payload_envelope.rs +++ b/consensus/types/src/execution/signed_execution_payload_envelope.rs @@ -1,8 +1,14 @@ use crate::test_utils::TestRandom; -use crate::{EthSpec, ExecutionPayloadEnvelope}; -use bls::Signature; +use crate::{ + BeaconState, BeaconStateError, ChainSpec, Domain, Epoch, EthSpec, ExecutionBlockHash, + ExecutionPayloadEnvelope, Fork, ForkName, Hash256, SignedRoot, Slot, + consts::gloas::BUILDER_INDEX_SELF_BUILD, +}; +use bls::{PublicKey, Signature}; +use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; +use ssz::Encode; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -10,11 +16,106 @@ use tree_hash_derive::TreeHash; #[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TestRandom, TreeHash, Educe)] #[educe(PartialEq, Hash(bound(E: EthSpec)))] #[serde(bound = "E: EthSpec")] +#[context_deserialize(ForkName)] pub struct SignedExecutionPayloadEnvelope<E: EthSpec> { pub message: ExecutionPayloadEnvelope<E>, pub signature: Signature, } +impl<E: EthSpec> SignedExecutionPayloadEnvelope<E> { + /// Returns the minimum SSZ-encoded size (all variable-length fields empty). + pub fn min_size() -> usize { + Self { + message: ExecutionPayloadEnvelope::empty(), + signature: Signature::empty(), + } + .as_ssz_bytes() + .len() + } + + /// Returns the maximum SSZ-encoded size. + #[allow(clippy::arithmetic_side_effects)] + pub fn max_size() -> usize { + // Signature is fixed-size, so the variable-length delta is entirely from the envelope. + Self::min_size() + ExecutionPayloadEnvelope::<E>::max_size() + - ExecutionPayloadEnvelope::<E>::min_size() + } + + pub fn slot(&self) -> Slot { + self.message.slot() + } + + pub fn epoch(&self) -> Epoch { + self.slot().epoch(E::slots_per_epoch()) + } + + pub fn beacon_block_root(&self) -> Hash256 { + self.message.beacon_block_root + } + + pub fn block_hash(&self) -> ExecutionBlockHash { + self.message.payload.block_hash + } + + /// Verify `self.signature`. + pub fn verify_signature( + &self, + pubkey: &PublicKey, + fork: &Fork, + genesis_validators_root: Hash256, + spec: &ChainSpec, + ) -> bool { + // Signed envelopes using the new BeaconBuilder domain per the spec: + // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/beacon-chain.md#new-verify_execution_payload_envelope_signature + let domain = spec.get_domain( + self.epoch(), + Domain::BeaconBuilder, + fork, + genesis_validators_root, + ); + + let message = self.message.signing_root(domain); + + self.signature.verify(pubkey, message) + } + + /// Verify `self.signature` using keys drawn from the beacon state. + pub fn verify_signature_with_state( + &self, + state: &BeaconState<E>, + spec: &ChainSpec, + ) -> Result<bool, BeaconStateError> { + let builder_index = self.message.builder_index; + + let pubkey_bytes = if builder_index == BUILDER_INDEX_SELF_BUILD { + let validator_index = state.latest_block_header().proposer_index; + state.get_validator(validator_index as usize)?.pubkey + } else { + state.get_builder(builder_index)?.pubkey + }; + + // TODO(gloas): Could use pubkey cache on state here, but it probably isn't worth + // it because this function is rarely used. Almost always the envelope should be signature + // verified prior to consensus code running. + let pubkey = pubkey_bytes.decompress()?; + + // Ensure the state's epoch matches the message's epoch before determining the Fork. + if self.epoch() != state.current_epoch() { + return Err(BeaconStateError::SignedEnvelopeIncorrectEpoch { + state_epoch: state.current_epoch(), + envelope_epoch: self.epoch(), + }); + } + + Ok(self.verify_signature( + &pubkey, + &state.fork(), + state.genesis_validators_root(), + spec, + )) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/consensus/types/src/kzg_ext/mod.rs b/consensus/types/src/kzg_ext/mod.rs index 63533ec71f..09305716ab 100644 --- a/consensus/types/src/kzg_ext/mod.rs +++ b/consensus/types/src/kzg_ext/mod.rs @@ -1,10 +1,12 @@ pub mod consts; -pub use kzg::{Blob as KzgBlob, Error as KzgError, Kzg, KzgCommitment, KzgProof}; - -use ssz_types::VariableList; +pub use kzg::{Error as KzgError, Kzg, KzgCommitment, KzgProof}; use crate::core::EthSpec; +use crate::{BeaconStateError, Hash256}; +use merkle_proof::{MerkleTree, MerkleTreeError}; +use ssz_types::{FixedVector, VariableList}; +use tree_hash::{BYTES_PER_CHUNK, TreeHash}; // Note on List limit: // - Deneb to Electra: `MaxBlobCommitmentsPerBlock` @@ -25,3 +27,49 @@ pub fn format_kzg_commitments(commitments: &[KzgCommitment]) -> String { let surrounded_commitments = format!("[{}]", commitments_joined); surrounded_commitments } + +pub fn complete_kzg_commitment_merkle_proof<E: EthSpec>( + kzg_commitments: &KzgCommitments<E>, + index: usize, + kzg_commitments_proof: &[Hash256], +) -> Result<FixedVector<Hash256, E::KzgCommitmentInclusionProofDepth>, BeaconStateError> { + // We compute the branches by generating 2 merkle trees: + // 1. Merkle tree for the `blob_kzg_commitments` List object + // 2. Merkle tree for the `BeaconBlockBody` container + // We then merge the branches for both the trees all the way up to the root. + + // Part1 (Branches for the subtree rooted at `blob_kzg_commitments`) + // + // Branches for `blob_kzg_commitments` without length mix-in + let blob_leaves = kzg_commitments + .iter() + .map(|commitment| commitment.tree_hash_root()) + .collect::<Vec<_>>(); + let depth = E::max_blob_commitments_per_block() + .next_power_of_two() + .ilog2(); + let tree = MerkleTree::create(&blob_leaves, depth as usize); + let (_, mut proof) = tree + .generate_proof(index, depth as usize) + .map_err(BeaconStateError::MerkleTreeError)?; + + // Add the branch corresponding to the length mix-in. + let length = blob_leaves.len(); + let usize_len = std::mem::size_of::<usize>(); + let mut length_bytes = [0; BYTES_PER_CHUNK]; + length_bytes + .get_mut(0..usize_len) + .ok_or(BeaconStateError::MerkleTreeError( + MerkleTreeError::PleaseNotifyTheDevs, + ))? + .copy_from_slice(&length.to_le_bytes()); + let length_root = Hash256::from_slice(length_bytes.as_slice()); + proof.push(length_root); + + // Part 2 + // Branches for `BeaconBlockBody` container + // Join the proofs for the subtree and the main tree + proof.extend_from_slice(kzg_commitments_proof); + + Ok(FixedVector::new(proof)?) +} diff --git a/consensus/types/src/lib.rs b/consensus/types/src/lib.rs index 5a89fcb1d4..f6d94331de 100644 --- a/consensus/types/src/lib.rs +++ b/consensus/types/src/lib.rs @@ -49,129 +49,6 @@ pub use sync_committee::*; pub use validator::*; pub use withdrawal::*; -// Temporary facade modules to maintain backwards compatibility for Lighthouse. -pub mod eth_spec { - pub use crate::core::EthSpec; -} - -pub mod chain_spec { - pub use crate::core::ChainSpec; -} - -pub mod beacon_block { - pub use crate::block::{BlindedBeaconBlock, BlockImportSource}; -} - -pub mod beacon_block_body { - pub use crate::kzg_ext::{KzgCommitments, format_kzg_commitments}; -} - -pub mod beacon_state { - pub use crate::state::{ - BeaconState, BeaconStateBase, CommitteeCache, compute_committee_index_in_epoch, - compute_committee_range_in_epoch, epoch_committee_count, - }; -} - pub mod graffiti { - pub use crate::core::GraffitiString; + pub use crate::core::{GRAFFITI_BYTES_LEN, Graffiti, GraffitiString}; } - -pub mod indexed_attestation { - pub use crate::attestation::{IndexedAttestationBase, IndexedAttestationElectra}; -} - -pub mod historical_summary { - pub use crate::state::HistoricalSummary; -} - -pub mod participation_flags { - pub use crate::attestation::ParticipationFlags; -} - -pub mod epoch_cache { - pub use crate::state::{EpochCache, EpochCacheError, EpochCacheKey}; -} - -pub mod non_zero_usize { - pub use crate::core::new_non_zero_usize; -} - -pub mod data_column_sidecar { - pub use crate::data::{ - Cell, ColumnIndex, DataColumn, DataColumnSidecar, DataColumnSidecarError, - DataColumnSidecarList, - }; -} - -pub mod builder_bid { - pub use crate::builder::*; -} - -pub mod blob_sidecar { - pub use crate::data::{ - BlobIdentifier, BlobSidecar, BlobSidecarError, BlobsList, FixedBlobSidecarList, - }; -} - -pub mod payload { - pub use crate::execution::BlockProductionVersion; -} - -pub mod execution_requests { - pub use crate::execution::{ - ConsolidationRequests, DepositRequests, ExecutionRequests, RequestType, WithdrawalRequests, - }; -} - -pub mod execution_payload_envelope { - pub use crate::execution::{ExecutionPayloadEnvelope, SignedExecutionPayloadEnvelope}; -} - -pub mod data_column_custody_group { - pub use crate::data::{ - CustodyIndex, compute_columns_for_custody_group, compute_ordered_custody_column_indices, - compute_subnets_for_node, compute_subnets_from_custody_group, get_custody_groups, - }; -} - -pub mod sync_aggregate { - pub use crate::sync_committee::SyncAggregateError as Error; -} - -pub mod light_client_update { - pub use crate::light_client::consts::{ - CURRENT_SYNC_COMMITTEE_INDEX, CURRENT_SYNC_COMMITTEE_INDEX_ELECTRA, FINALIZED_ROOT_INDEX, - FINALIZED_ROOT_INDEX_ELECTRA, MAX_REQUEST_LIGHT_CLIENT_UPDATES, NEXT_SYNC_COMMITTEE_INDEX, - NEXT_SYNC_COMMITTEE_INDEX_ELECTRA, - }; -} - -pub mod sync_committee_contribution { - pub use crate::sync_committee::{ - SyncCommitteeContributionError as Error, SyncContributionData, - }; -} - -pub mod slot_data { - pub use crate::core::SlotData; -} - -pub mod signed_aggregate_and_proof { - pub use crate::attestation::SignedAggregateAndProofRefMut; -} - -pub mod payload_attestation { - pub use crate::attestation::{ - PayloadAttestation, PayloadAttestationData, PayloadAttestationMessage, - }; -} - -pub mod application_domain { - pub use crate::core::ApplicationDomain; -} - -// Temporary re-exports to maintain backwards compatibility for Lighthouse. -pub use crate::kzg_ext::consts::VERSIONED_HASH_VERSION_KZG; -pub use crate::light_client::LightClientError as LightClientUpdateError; -pub use crate::state::BeaconStateError as Error; diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 4c1c2085b9..52be7fd029 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -9,11 +9,12 @@ use fixed_bytes::FixedBytesExtended; use int_to_bytes::{int_to_bytes4, int_to_bytes8}; use metastruct::{NumFields, metastruct}; use milhouse::{List, Vector}; -use safe_arith::{ArithError, SafeArith}; +use safe_arith::{ArithError, SafeArith, SafeArithIter}; use serde::{Deserialize, Deserializer, Serialize}; use ssz::{Decode, DecodeError, Encode, ssz_encode}; use ssz_derive::{Decode, Encode}; use ssz_types::{BitVector, FixedVector}; +use std::collections::BTreeMap; use superstruct::superstruct; use swap_or_not_shuffle::compute_shuffled_index; use test_random_derive::TestRandom; @@ -23,12 +24,13 @@ use tree_hash_derive::TreeHash; use typenum::Unsigned; use crate::{ - BuilderPendingPayment, BuilderPendingWithdrawal, ExecutionBlockHash, ExecutionPayloadBid, + Address, ExecutionBlockHash, ExecutionPayloadBid, ProposerPreferences, Withdrawal, attestation::{ - AttestationDuty, BeaconCommittee, Checkpoint, CommitteeIndex, ParticipationFlags, - PendingAttestation, + AttestationData, AttestationDuty, BeaconCommittee, Checkpoint, CommitteeIndex, PTC, + ParticipationFlags, PendingAttestation, }, block::{BeaconBlock, BeaconBlockHeader, SignedBeaconBlockHash}, + builder::{Builder, BuilderIndex, BuilderPendingPayment, BuilderPendingWithdrawal}, consolidation::PendingConsolidation, core::{ChainSpec, Domain, Epoch, EthSpec, Hash256, RelativeEpoch, RelativeEpochError, Slot}, deposit::PendingDeposit, @@ -55,10 +57,24 @@ use crate::{ }; pub const CACHED_EPOCHS: usize = 3; + +// Pre-electra WS calculations are not supported. On mainnet, pre-electra epochs are outside the +// weak subjectivity period. The default pre-electra WS value is set to 256 to allow for `basic-sim` +// and `fallback-sim` tests to pass. 256 is a small enough number to trigger the WS safety check +// pre-electra on mainnet. +pub const DEFAULT_PRE_ELECTRA_WS_PERIOD: u64 = 256; + const MAX_RANDOM_BYTE: u64 = (1 << 8) - 1; const MAX_RANDOM_VALUE: u64 = (1 << 16) - 1; -pub type Validators<E> = List<Validator, <E as EthSpec>::ValidatorRegistryLimit>; +// `SAFETY_DECAY` is defined as the maximum percentage tolerable loss in the one-third +// safety margin of FFG finality. Thus, any attack exploiting the Weak Subjectivity Period has +// a safety margin of at least `1/3 - SAFETY_DECAY/100`. +// Spec: https://github.com/ethereum/consensus-specs/blob/1937aff86b41b5171a9bc3972515986f1bbbf303/specs/phase0/weak-subjectivity.md?plain=1#L50-L71 +const SAFETY_DECAY: u64 = 10; + +pub type Validators<E> = + List<Validator, <E as EthSpec>::ValidatorRegistryLimit, BTreeMap<usize, Validator>>; pub type Balances<E> = List<u64, <E as EthSpec>::ValidatorRegistryLimit>; #[derive(Debug, PartialEq, Clone)] @@ -68,6 +84,7 @@ pub enum BeaconStateError { EpochOutOfBounds, SlotOutOfBounds, UnknownValidator(usize), + UnknownBuilder(BuilderIndex), UnableToDetermineProducer, InvalidBitfield, EmptyCommittee, @@ -173,8 +190,12 @@ pub enum BeaconStateError { MerkleTreeError(merkle_proof::MerkleTreeError), PartialWithdrawalCountInvalid(usize), NonExecutionAddressWithdrawalCredential, + WithdrawalCredentialMissingVersion, + WithdrawalCredentialMissingAddress, NoCommitteeFound(CommitteeIndex), InvalidCommitteeIndex(CommitteeIndex), + /// `Attestation.data.index` field is invalid in overloaded data index scenario. + BadOverloadedDataIndex(u64), InvalidSelectionProof { aggregator_index: u64, }, @@ -196,6 +217,13 @@ pub enum BeaconStateError { ProposerLookaheadOutOfBounds { i: usize, }, + SignedEnvelopeIncorrectEpoch { + state_epoch: Epoch, + envelope_epoch: Epoch, + }, + InvalidIndicesCount, + InvalidBuilderPendingPaymentsIndex(usize), + InvalidExecutionPayloadAvailabilityIndex(usize), } /// Control whether an epoch-indexed field can be indexed at the next epoch or not. @@ -466,7 +494,7 @@ where // Registry #[compare_fields(as_iter)] #[test_random(default)] - pub validators: List<Validator, E::ValidatorRegistryLimit>, + pub validators: Validators<E>, #[serde(with = "ssz_types::serde_utils::quoted_u64_var_list")] #[compare_fields(as_iter)] #[test_random(default)] @@ -564,9 +592,10 @@ where )] #[metastruct(exclude_from(tree_lists))] pub latest_execution_payload_header: ExecutionPayloadHeaderEip7805<E>, + #[test_random(default)] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] - pub latest_execution_payload_bid: ExecutionPayloadBid, + pub latest_block_hash: ExecutionBlockHash, #[superstruct( only(Capella, Deneb, Electra, Fulu, Eip7805, Gloas), partial_getter(copy) @@ -629,8 +658,17 @@ where #[superstruct(only(Fulu, Eip7805, Gloas))] #[serde(with = "ssz_types::serde_utils::quoted_u64_fixed_vec")] pub proposer_lookahead: Vector<u64, E::ProposerLookaheadSlots>, - // Gloas + #[compare_fields(as_iter)] + #[test_random(default)] + #[superstruct(only(Gloas))] + pub builders: List<Builder, E::BuilderRegistryLimit>, + + #[metastruct(exclude_from(tree_lists))] + #[serde(with = "serde_utils::quoted_u64")] + #[superstruct(only(Gloas), partial_getter(copy))] + pub next_withdrawal_builder_index: BuilderIndex, + #[test_random(default)] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] @@ -647,15 +685,19 @@ where pub builder_pending_withdrawals: List<BuilderPendingWithdrawal, E::BuilderPendingWithdrawalsLimit>, - #[test_random(default)] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] - pub latest_block_hash: ExecutionBlockHash, + pub latest_execution_payload_bid: ExecutionPayloadBid<E>, + #[compare_fields(as_iter)] #[test_random(default)] #[superstruct(only(Gloas))] - #[metastruct(exclude_from(tree_lists))] - pub latest_withdrawals_root: Hash256, + pub payload_expected_withdrawals: List<Withdrawal, E::MaxWithdrawalsPerPayload>, + + #[compare_fields(as_iter)] + #[test_random(default)] + #[superstruct(only(Gloas))] + pub ptc_window: Vector<FixedVector<u64, E::PTCSize>, E::PtcWindowLength>, // Caching (not in the spec) #[serde(skip_serializing, skip_deserializing)] @@ -870,7 +912,7 @@ impl<E: EthSpec> BeaconState<E> { relative_epoch: RelativeEpoch, ) -> Result<u64, BeaconStateError> { let cache = self.committee_cache(relative_epoch)?; - Ok(cache.epoch_committee_count() as u64) + Ok(cache.epoch_committee_count()? as u64) } /// Return the cached active validator indices at some epoch. @@ -1192,13 +1234,22 @@ impl<E: EthSpec> BeaconState<E> { } } + let gloas_enabled = self.fork_name_unchecked().gloas_enabled(); epoch .slot_iter(E::slots_per_epoch()) .map(|slot| { let mut preimage = seed.to_vec(); preimage.append(&mut int_to_bytes8(slot.as_u64())); let seed = hash(&preimage); - self.compute_proposer_index(indices, &seed, spec) + + if gloas_enabled { + self.compute_balance_weighted_selection(indices, &seed, 1, true, spec)? + .first() + .copied() + .ok_or(BeaconStateError::InsufficientValidators) + } else { + self.compute_proposer_index(indices, &seed, spec) + } }) .collect() } @@ -1363,6 +1414,43 @@ impl<E: EthSpec> BeaconState<E> { } } + /// Check if the validator is the proposer for the given slot in the current or next epoch. + pub fn is_valid_proposal_slot( + &self, + preferences: &ProposerPreferences, + ) -> Result<bool, BeaconStateError> { + let current_epoch = self.current_epoch(); + let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); + + if proposal_epoch < current_epoch { + return Ok(false); + } + + let next_epoch = current_epoch.saturating_add(1u64); + if proposal_epoch > next_epoch { + return Ok(false); + } + + let epoch_offset = proposal_epoch.as_u64().safe_sub(current_epoch.as_u64())?; + + let slot_in_epoch = preferences + .proposal_slot + .as_u64() + .safe_rem(E::slots_per_epoch())?; + + let index = epoch_offset + .safe_mul(E::slots_per_epoch()) + .and_then(|v| v.safe_add(slot_in_epoch))?; + + let proposer_lookahead = self.proposer_lookahead()?; + + let proposer = proposer_lookahead + .get(index as usize) + .ok_or(BeaconStateError::ProposerLookaheadOutOfBounds { i: index as usize })?; + + Ok(*proposer == preferences.validator_index) + } + /// Returns the beacon proposer index for each `slot` in `epoch`. /// /// The returned `Vec` contains one proposer index for each slot in the epoch. @@ -1461,39 +1549,50 @@ impl<E: EthSpec> BeaconState<E> { let epoch = self.current_epoch().safe_add(1)?; let active_validator_indices = self.get_active_validator_indices(epoch, spec)?; - let active_validator_count = active_validator_indices.len(); - let seed = self.get_seed(epoch, Domain::SyncCommittee, spec)?; - let max_effective_balance = spec.max_effective_balance_for_fork(self.fork_name_unchecked()); - let max_random_value = if self.fork_name_unchecked().electra_enabled() { - MAX_RANDOM_VALUE - } else { - MAX_RANDOM_BYTE - }; - let mut i = 0; - let mut sync_committee_indices = Vec::with_capacity(E::SyncCommitteeSize::to_usize()); - while sync_committee_indices.len() < E::SyncCommitteeSize::to_usize() { - let shuffled_index = compute_shuffled_index( - i.safe_rem(active_validator_count)?, - active_validator_count, + if self.fork_name_unchecked().gloas_enabled() { + self.compute_balance_weighted_selection( + &active_validator_indices, seed.as_slice(), - spec.shuffle_round_count, + E::SyncCommitteeSize::to_usize(), + true, + spec, ) - .ok_or(BeaconStateError::UnableToShuffle)?; - let candidate_index = *active_validator_indices - .get(shuffled_index) - .ok_or(BeaconStateError::ShuffleIndexOutOfBounds(shuffled_index))?; - let random_value = self.shuffling_random_value(i, seed.as_slice())?; - let effective_balance = self.get_validator(candidate_index)?.effective_balance; - if effective_balance.safe_mul(max_random_value)? - >= max_effective_balance.safe_mul(random_value)? - { - sync_committee_indices.push(candidate_index); + } else { + let active_validator_count = active_validator_indices.len(); + let max_effective_balance = + spec.max_effective_balance_for_fork(self.fork_name_unchecked()); + let max_random_value = if self.fork_name_unchecked().electra_enabled() { + MAX_RANDOM_VALUE + } else { + MAX_RANDOM_BYTE + }; + + let mut i = 0; + let mut sync_committee_indices = Vec::with_capacity(E::SyncCommitteeSize::to_usize()); + while sync_committee_indices.len() < E::SyncCommitteeSize::to_usize() { + let shuffled_index = compute_shuffled_index( + i.safe_rem(active_validator_count)?, + active_validator_count, + seed.as_slice(), + spec.shuffle_round_count, + ) + .ok_or(BeaconStateError::UnableToShuffle)?; + let candidate_index = *active_validator_indices + .get(shuffled_index) + .ok_or(BeaconStateError::ShuffleIndexOutOfBounds(shuffled_index))?; + let random_value = self.shuffling_random_value(i, seed.as_slice())?; + let effective_balance = self.get_validator(candidate_index)?.effective_balance; + if effective_balance.safe_mul(max_random_value)? + >= max_effective_balance.safe_mul(random_value)? + { + sync_committee_indices.push(candidate_index); + } + i.safe_add_assign(1)?; } - i.safe_add_assign(1)?; + Ok(sync_committee_indices) } - Ok(sync_committee_indices) } /// Compute the next sync committee. @@ -1978,6 +2077,15 @@ impl<E: EthSpec> BeaconState<E> { .ok_or(BeaconStateError::UnknownValidator(validator_index)) } + /// Safe indexer for the `builders` list. + /// + /// Will return an error pre-Gloas, or for out-of-bounds indices. + pub fn get_builder(&self, builder_index: BuilderIndex) -> Result<&Builder, BeaconStateError> { + self.builders()? + .get(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index)) + } + /// Add a validator to the registry and return the validator index that was allocated for it. pub fn add_validator_to_registry( &mut self, @@ -2024,6 +2132,64 @@ impl<E: EthSpec> BeaconState<E> { Ok(index) } + /// Add a builder to the registry and return the builder index that was allocated for it. + pub fn add_builder_to_registry( + &mut self, + pubkey: PublicKeyBytes, + withdrawal_credentials: Hash256, + amount: u64, + slot: Slot, + spec: &ChainSpec, + ) -> Result<BuilderIndex, BeaconStateError> { + // We are not yet using the spec's `set_or_append_list`, but could consider it if it crops + // up elsewhere. It has been retconned into the spec to support index reuse but so far + // index reuse is only relevant for builders. + let builder_index = self.get_index_for_new_builder()?; + let builders = self.builders_mut()?; + + let version = *withdrawal_credentials + .as_slice() + .first() + .ok_or(BeaconStateError::WithdrawalCredentialMissingVersion)?; + let execution_address = withdrawal_credentials + .as_slice() + .get(12..) + .and_then(|bytes| Address::try_from(bytes).ok()) + .ok_or(BeaconStateError::WithdrawalCredentialMissingAddress)?; + + let builder = Builder { + pubkey, + version, + execution_address, + balance: amount, + deposit_epoch: slot.epoch(E::slots_per_epoch()), + withdrawable_epoch: spec.far_future_epoch, + }; + + if builder_index == builders.len() as u64 { + builders.push(builder)?; + } else { + *builders + .get_mut(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))? = builder; + } + Ok(builder_index) + } + + // TODO(gloas): Optimize this function if we see a lot of registered builders on-chain. + // A cache here could be quite fiddly because this calculation depends on withdrawable epoch + // and balance - a cache for this would need to be updated whenever either of those fields + // changes. + pub fn get_index_for_new_builder(&self) -> Result<BuilderIndex, BeaconStateError> { + let current_epoch = self.current_epoch(); + for (index, builder) in self.builders()?.iter().enumerate() { + if builder.withdrawable_epoch <= current_epoch && builder.balance == 0 { + return Ok(index as u64); + } + } + Ok(self.builders()?.len() as u64) + } + /// Safe copy-on-write accessor for the `validators` list. pub fn get_validator_cow( &mut self, @@ -2123,7 +2289,26 @@ impl<E: EthSpec> BeaconState<E> { ) -> Result<Option<AttestationDuty>, BeaconStateError> { let cache = self.committee_cache(relative_epoch)?; - Ok(cache.get_attestation_duties(validator_index)) + Ok(cache.get_attestation_duties(validator_index)?) + } + + /// Check if the attestation is for the block proposed at the attestation slot. + /// + /// Returns `true` if the attestation's block root matches the block root at the + /// attestation's slot, and the block root differs from the previous slot's root. + pub fn is_attestation_same_slot( + &self, + data: &AttestationData, + ) -> Result<bool, BeaconStateError> { + if data.slot == 0 { + return Ok(true); + } + + let block_root = data.beacon_block_root; + let slot_block_root = *self.get_block_root(data.slot)?; + let prev_block_root = *self.get_block_root(data.slot.safe_sub(1)?)?; + + Ok(block_root == slot_block_root && block_root != prev_block_root) } pub fn get_inclusion_list_duties( @@ -2389,6 +2574,18 @@ impl<E: EthSpec> BeaconState<E> { CommitteeCache::initialized(self, epoch, spec) } + /// Like [`initialize_committee_cache`](Self::initialize_committee_cache), but allows epochs + /// beyond `current_epoch + 1`. Only checks that the required randao seed is available. + /// + /// Used by PTC window computation which needs shufflings for lookahead epochs. + pub fn initialize_committee_cache_for_lookahead( + &self, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result<Arc<CommitteeCache>, BeaconStateError> { + CommitteeCache::initialized_for_lookahead(self, epoch, spec) + } + /// Advances the cache for this state into the next epoch. /// /// This should be used if the `slot` of this state is advanced beyond an epoch boundary. @@ -2427,6 +2624,7 @@ impl<E: EthSpec> BeaconState<E> { } } + /// Get the committee cache for some `slot`. /// /// Return an error if the cache for the slot's epoch is not initialized. @@ -2459,6 +2657,17 @@ impl<E: EthSpec> BeaconState<E> { .ok_or(BeaconStateError::CommitteeCachesOutOfBounds(index)) } + /// Set the committee cache for the given `relative_epoch` to `cache`. + pub fn set_committee_cache( + &mut self, + relative_epoch: RelativeEpoch, + cache: Arc<CommitteeCache>, + ) -> Result<(), BeaconStateError> { + let i = Self::committee_cache_index(relative_epoch); + *self.committee_cache_at_index_mut(i)? = cache; + Ok(()) + } + /// Returns the cache for some `RelativeEpoch`. Returns an error if the cache has not been /// initialized. pub fn committee_cache( @@ -2656,34 +2865,96 @@ impl<E: EthSpec> BeaconState<E> { self.epoch_cache().get_base_reward(validator_index) } + /// Get the proportional slashing multiplier for the current fork. + pub fn get_proportional_slashing_multiplier(&self, spec: &ChainSpec) -> u64 { + let fork_name = self.fork_name_unchecked(); + if fork_name >= ForkName::Bellatrix { + spec.proportional_slashing_multiplier_bellatrix + } else if fork_name >= ForkName::Altair { + spec.proportional_slashing_multiplier_altair + } else { + spec.proportional_slashing_multiplier + } + } + + /// Get the minimum slashing penalty quotient for the current fork. + pub fn get_min_slashing_penalty_quotient(&self, spec: &ChainSpec) -> u64 { + let fork_name = self.fork_name_unchecked(); + if fork_name.electra_enabled() { + spec.min_slashing_penalty_quotient_electra + } else if fork_name >= ForkName::Bellatrix { + spec.min_slashing_penalty_quotient_bellatrix + } else if fork_name >= ForkName::Altair { + spec.min_slashing_penalty_quotient_altair + } else { + spec.min_slashing_penalty_quotient + } + } + + /// Get the whistleblower reward quotient for the current fork. + pub fn get_whistleblower_reward_quotient(&self, spec: &ChainSpec) -> u64 { + let fork_name = self.fork_name_unchecked(); + if fork_name.electra_enabled() { + spec.whistleblower_reward_quotient_electra + } else { + spec.whistleblower_reward_quotient + } + } + // ******* Electra accessors ******* /// Return the churn limit for the current epoch. pub fn get_balance_churn_limit(&self, spec: &ChainSpec) -> Result<u64, BeaconStateError> { let total_active_balance = self.get_total_active_balance()?; + let quotient = if self.fork_name_unchecked().gloas_enabled() { + spec.churn_limit_quotient_gloas + } else { + spec.churn_limit_quotient + }; let churn = std::cmp::max( spec.min_per_epoch_churn_limit_electra, - total_active_balance.safe_div(spec.churn_limit_quotient)?, + total_active_balance.safe_div(quotient)?, ); Ok(churn.safe_sub(churn.safe_rem(spec.effective_balance_increment)?)?) } /// Return the churn limit for the current epoch dedicated to activations and exits. + /// + /// From Gloas onwards this is the activation-only churn limit (EIP-8061); exits use + /// [`Self::get_exit_churn_limit`]. pub fn get_activation_exit_churn_limit( &self, spec: &ChainSpec, ) -> Result<u64, BeaconStateError> { + let max_limit = if self.fork_name_unchecked().gloas_enabled() { + spec.max_per_epoch_activation_churn_limit_gloas + } else { + spec.max_per_epoch_activation_exit_churn_limit + }; Ok(std::cmp::min( - spec.max_per_epoch_activation_exit_churn_limit, + max_limit, self.get_balance_churn_limit(spec)?, )) } + /// Return the Gloas (EIP-8061) exit churn limit for the current epoch. + /// + /// Unlike [`Self::get_activation_exit_churn_limit`], this is uncapped. + pub fn get_exit_churn_limit(&self, spec: &ChainSpec) -> Result<u64, BeaconStateError> { + self.get_balance_churn_limit(spec) + } + pub fn get_consolidation_churn_limit(&self, spec: &ChainSpec) -> Result<u64, BeaconStateError> { - self.get_balance_churn_limit(spec)? - .safe_sub(self.get_activation_exit_churn_limit(spec)?) - .map_err(Into::into) + if self.fork_name_unchecked().gloas_enabled() { + let total_active_balance = self.get_total_active_balance()?; + let churn = total_active_balance.safe_div(spec.consolidation_churn_limit_quotient)?; + Ok(churn.safe_sub(churn.safe_rem(spec.effective_balance_increment)?)?) + } else { + self.get_balance_churn_limit(spec)? + .safe_sub(self.get_activation_exit_churn_limit(spec)?) + .map_err(Into::into) + } } pub fn get_pending_balance_to_withdraw( @@ -2701,6 +2972,30 @@ impl<E: EthSpec> BeaconState<E> { Ok(pending_balance) } + pub fn get_pending_balance_to_withdraw_for_builder( + &self, + builder_index: BuilderIndex, + ) -> Result<u64, BeaconStateError> { + let pending_withdrawals_total = self + .builder_pending_withdrawals()? + .iter() + .filter_map(|withdrawal| { + (withdrawal.builder_index == builder_index).then_some(withdrawal.amount) + }) + .safe_sum()?; + let pending_payments_total = self + .builder_pending_payments()? + .iter() + .filter_map(|payment| { + (payment.withdrawal.builder_index == builder_index) + .then_some(payment.withdrawal.amount) + }) + .safe_sum()?; + pending_withdrawals_total + .safe_add(pending_payments_total) + .map_err(Into::into) + } + // ******* Electra mutators ******* pub fn queue_excess_active_balance( @@ -2754,7 +3049,11 @@ impl<E: EthSpec> BeaconState<E> { self.compute_activation_exit_epoch(self.current_epoch(), spec)?, ); - let per_epoch_churn = self.get_activation_exit_churn_limit(spec)?; + let per_epoch_churn = if self.fork_name_unchecked().gloas_enabled() { + self.get_exit_churn_limit(spec)? + } else { + self.get_activation_exit_churn_limit(spec)? + }; // New epoch for exits let mut exit_balance_to_consume = if self.earliest_exit_epoch()? < earliest_exit_epoch { per_epoch_churn @@ -2842,7 +3141,6 @@ impl<E: EthSpec> BeaconState<E> { } } - #[allow(clippy::arithmetic_side_effects)] pub fn rebase_on(&mut self, base: &Self, spec: &ChainSpec) -> Result<(), BeaconStateError> { // Required for macros (which use type-hints internally). @@ -2984,6 +3282,239 @@ impl<E: EthSpec> BeaconState<E> { Ok(()) } + + /// Returns the weak subjectivity period for `self` + pub fn compute_weak_subjectivity_period( + &self, + spec: &ChainSpec, + ) -> Result<Epoch, BeaconStateError> { + let total_active_balance = self.get_total_active_balance()?; + let fork_name = self.fork_name_unchecked(); + + if fork_name.gloas_enabled() { + // [Modified in Gloas:EIP8061] + let exit_churn = self.get_exit_churn_limit(spec)?; + let activation_churn = self.get_activation_exit_churn_limit(spec)?; + let consolidation_churn = self.get_consolidation_churn_limit(spec)?; + compute_weak_subjectivity_period_gloas( + total_active_balance, + exit_churn, + activation_churn, + consolidation_churn, + spec, + ) + } else if fork_name.electra_enabled() { + let balance_churn_limit = self.get_balance_churn_limit(spec)?; + compute_weak_subjectivity_period_electra( + total_active_balance, + balance_churn_limit, + spec, + ) + } else { + Ok(Epoch::new(DEFAULT_PRE_ELECTRA_WS_PERIOD)) + } + } + + /// Get the payload timeliness committee for the given `slot` from the `ptc_window`. + pub fn get_ptc(&self, slot: Slot, spec: &ChainSpec) -> Result<PTC<E>, BeaconStateError> { + let ptc_window = self.ptc_window()?; + let epoch = slot.epoch(E::slots_per_epoch()); + let state_epoch = self.current_epoch(); + let slots_per_epoch = E::slots_per_epoch() as usize; + let slot_in_epoch = slot.as_usize().safe_rem(slots_per_epoch)?; + + let index = if epoch < state_epoch { + if epoch.safe_add(1)? != state_epoch { + return Err(BeaconStateError::SlotOutOfBounds); + } + slot_in_epoch + } else { + if epoch > state_epoch.safe_add(spec.min_seed_lookahead)? { + return Err(BeaconStateError::SlotOutOfBounds); + } + let offset = epoch + .safe_sub(state_epoch)? + .safe_add(1)? + .as_usize() + .safe_mul(slots_per_epoch)?; + offset.safe_add(slot_in_epoch)? + }; + + let entry = ptc_window + .get(index) + .ok_or(BeaconStateError::SlotOutOfBounds)?; + + // Convert from FixedVector<u64, PTCSize> to PTC<E> (FixedVector<usize, PTCSize>) + let indices: Vec<usize> = entry.iter().map(|&v| v as usize).collect(); + Ok(PTC(FixedVector::new(indices)?)) + } + + /// Compute the payload timeliness committee for the given `slot` from scratch. + /// + /// Requires the committee cache to be initialized for the slot's epoch. + pub fn compute_ptc(&self, slot: Slot, spec: &ChainSpec) -> Result<PTC<E>, BeaconStateError> { + let committee_cache = self.committee_cache_at_slot(slot)?; + self.compute_ptc_with_cache(slot, committee_cache, spec) + } + + /// Compute the PTC for a slot using a specific committee cache. + pub fn compute_ptc_with_cache( + &self, + slot: Slot, + committee_cache: &CommitteeCache, + spec: &ChainSpec, + ) -> Result<PTC<E>, BeaconStateError> { + let committees = committee_cache.get_beacon_committees_at_slot(slot)?; + + let seed = self.get_ptc_attester_seed(slot, spec)?; + + let committee_indices: Vec<usize> = committees + .iter() + .flat_map(|committee| committee.committee.iter().copied()) + .collect(); + let selected_indices = self.compute_balance_weighted_selection( + &committee_indices, + &seed, + E::ptc_size(), + false, + spec, + )?; + + Ok(PTC(FixedVector::new(selected_indices)?)) + } + + /// Compute the seed to use for the ptc attester selection at the given `slot`. + pub fn get_ptc_attester_seed( + &self, + slot: Slot, + spec: &ChainSpec, + ) -> Result<Vec<u8>, BeaconStateError> { + let epoch = slot.epoch(E::slots_per_epoch()); + let mut preimage = self + .get_seed(epoch, Domain::PTCAttester, spec)? + .as_slice() + .to_vec(); + preimage.append(&mut int_to_bytes8(slot.as_u64())); + Ok(hash(&preimage)) + } + + /// Find the first slot in the given epoch where the validator is assigned to the PTC. + /// + /// Returns `Ok(Some(slot))` if the validator is in the PTC for any slot in the epoch, + /// `Ok(None)` if the validator is not in the PTC for this epoch. + /// + /// This iterates through all slots in the epoch, so it's O(slots_per_epoch) per validator. + pub fn get_ptc_assignment( + &self, + validator_index: usize, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result<Option<Slot>, BeaconStateError> { + for slot in epoch.slot_iter(E::slots_per_epoch()) { + let ptc = self.get_ptc(slot, spec)?; + if ptc.0.contains(&validator_index) { + return Ok(Some(slot)); + } + } + Ok(None) + } + + /// Return size indices sampled by effective balance, using indices as candidates. + /// + /// If shuffle_indices is True, candidate indices are themselves sampled from indices + /// by shuffling it, otherwise indices is traversed in order. + fn compute_balance_weighted_selection( + &self, + indices: &[usize], + seed: &[u8], + size: usize, + shuffle_indices: bool, + spec: &ChainSpec, + ) -> Result<Vec<usize>, BeaconStateError> { + let total = indices.len(); + if total == 0 { + return Err(BeaconStateError::InvalidIndicesCount); + } + + let mut selected = Vec::with_capacity(size); + let mut i = 0usize; + + while selected.len() < size { + let mut next_index = i.safe_rem(total)?; + + if shuffle_indices { + next_index = + compute_shuffled_index(next_index, total, seed, spec.shuffle_round_count) + .ok_or(BeaconStateError::UnableToShuffle)?; + } + + let candidate_index = indices + .get(next_index) + .ok_or(BeaconStateError::InvalidIndicesCount)?; + + if self.compute_balance_weighted_acceptance(*candidate_index, seed, i, spec)? { + selected.push(*candidate_index); + } + + i.safe_add_assign(1)?; + } + + Ok(selected) + } + + /// Return whether to accept the selection of the validator `index`, with probability + /// proportional to its `effective_balance`, and randomness given by `seed` and `iteration`. + fn compute_balance_weighted_acceptance( + &self, + index: usize, + seed: &[u8], + iteration: usize, + spec: &ChainSpec, + ) -> Result<bool, BeaconStateError> { + // TODO(EIP-7732): Consider grabbing effective balances from the epoch cache here. + // Note that this function will be used in a loop, so using cached values could be nice for performance. + // However, post-gloas, this function will be used in `compute_proposer_indices`, `get_next_sync_committee_indices`, and `get_ptc`, which has ~15 call sites in total + // so we will need to check each one to ensure epoch cache is initialized first, if we deem a good idea. + // Currently, we can't test if making the change would work since the test suite is not ready for gloas. + let effective_balance = self.get_effective_balance(index)?; + let max_effective_balance = spec.max_effective_balance_for_fork(self.fork_name_unchecked()); + let random_value = self.shuffling_random_value(iteration, seed)?; + + Ok(effective_balance.safe_mul(MAX_RANDOM_VALUE)? + >= max_effective_balance.safe_mul(random_value)?) + } + + pub fn can_builder_cover_bid( + &self, + builder_index: BuilderIndex, + bid_amount: u64, + spec: &ChainSpec, + ) -> Result<bool, BeaconStateError> { + let builder = self.get_builder(builder_index)?; + + let builder_balance = builder.balance; + let pending_withdrawals_amount = + self.get_pending_balance_to_withdraw_for_builder(builder_index)?; + + let min_balance = spec + .min_deposit_amount + .safe_add(pending_withdrawals_amount)?; + if builder_balance < min_balance { + return Ok(false); + } + Ok(builder_balance.safe_sub(min_balance)? >= bid_amount) + } + + pub fn is_active_builder( + &self, + builder_index: BuilderIndex, + spec: &ChainSpec, + ) -> Result<bool, BeaconStateError> { + let builder = self.get_builder(builder_index)?; + + Ok(builder.deposit_epoch < self.finalized_checkpoint().epoch + && builder.withdrawable_epoch == spec.far_future_epoch) + } } impl<E: EthSpec> ForkVersionDecode for BeaconState<E> { @@ -3035,7 +3566,6 @@ impl<E: EthSpec> BeaconState<E> { )) } - #[allow(clippy::arithmetic_side_effects)] pub fn apply_pending_mutations(&mut self) -> Result<(), BeaconStateError> { match self { Self::Base(inner) => { @@ -3141,7 +3671,6 @@ impl<E: EthSpec> BeaconState<E> { pub fn get_beacon_state_leaves(&self) -> Vec<Hash256> { let mut leaves = vec![]; - #[allow(clippy::arithmetic_side_effects)] match self { BeaconState::Base(state) => { map_beacon_state_base_fields!(state, |_, field| { @@ -3265,3 +3794,99 @@ impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for BeaconState<E> { )) } } + +/// Spec: https://github.com/ethereum/consensus-specs/blob/1937aff86b41b5171a9bc3972515986f1bbbf303/specs/electra/weak-subjectivity.md?plain=1#L30 +pub fn compute_weak_subjectivity_period_electra( + total_active_balance: u64, + balance_churn_limit: u64, + spec: &ChainSpec, +) -> Result<Epoch, BeaconStateError> { + let epochs_for_validator_set_churn = SAFETY_DECAY + .safe_mul(total_active_balance)? + .safe_div(balance_churn_limit.safe_mul(200)?)?; + let ws_period = spec + .min_validator_withdrawability_delay + .safe_add(epochs_for_validator_set_churn)?; + + Ok(ws_period) +} + +/// Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.6/specs/gloas/weak-subjectivity.md +pub fn compute_weak_subjectivity_period_gloas( + total_active_balance: u64, + exit_churn_limit: u64, + activation_churn_limit: u64, + consolidation_churn_limit: u64, + spec: &ChainSpec, +) -> Result<Epoch, BeaconStateError> { + // delta = 2 * exit_churn // 3 + activation_churn // 3 + consolidation_churn + let delta = exit_churn_limit + .safe_mul(2)? + .safe_div(3)? + .safe_add(activation_churn_limit.safe_div(3)?)? + .safe_add(consolidation_churn_limit)?; + let epochs_for_validator_set_churn = SAFETY_DECAY + .safe_mul(total_active_balance)? + .safe_div(delta.safe_mul(200)?)?; + let ws_period = spec + .min_validator_withdrawability_delay + .safe_add(epochs_for_validator_set_churn)?; + + Ok(ws_period) +} + +#[cfg(test)] +mod weak_subjectivity_tests { + use crate::state::beacon_state::compute_weak_subjectivity_period_electra; + use crate::{ChainSpec, Epoch, EthSpec, MainnetEthSpec}; + + const GWEI_PER_ETH: u64 = 1_000_000_000; + + #[test] + fn test_compute_weak_subjectivity_period_electra() { + let mut spec = MainnetEthSpec::default_spec(); + spec.altair_fork_epoch = Some(Epoch::new(0)); + spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + spec.capella_fork_epoch = Some(Epoch::new(0)); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.electra_fork_epoch = Some(Epoch::new(0)); + + // A table of some expected values: + // https://github.com/ethereum/consensus-specs/blob/1937aff86b41b5171a9bc3972515986f1bbbf303/specs/electra/weak-subjectivity.md?plain=1#L44-L54 + // (total_active_balance, expected_ws_period) + let expected_values: Vec<(u64, u64)> = vec![ + (1_048_576 * GWEI_PER_ETH, 665), + (2_097_152 * GWEI_PER_ETH, 1_075), + (4_194_304 * GWEI_PER_ETH, 1_894), + (8_388_608 * GWEI_PER_ETH, 3_532), + (16_777_216 * GWEI_PER_ETH, 3_532), + (33_554_432 * GWEI_PER_ETH, 3_532), + // This value cross referenced w/ + // beacon_chain/tests/tests.rs:test_compute_weak_subjectivity_period + (1536 * GWEI_PER_ETH, 256), + ]; + + for (total_active_balance, expected_ws_period) in expected_values { + let balance_churn_limit = get_balance_churn_limit(total_active_balance, &spec); + + let calculated_ws_period = compute_weak_subjectivity_period_electra( + total_active_balance, + balance_churn_limit, + &spec, + ) + .unwrap(); + + assert_eq!(calculated_ws_period, expected_ws_period); + } + } + + // caclulate the balance_churn_limit without dealing with states + // and without initializing the active balance cache + fn get_balance_churn_limit(total_active_balance: u64, spec: &ChainSpec) -> u64 { + let churn = std::cmp::max( + spec.min_per_epoch_churn_limit_electra, + total_active_balance / spec.churn_limit_quotient, + ); + churn - (churn % spec.effective_balance_increment) + } +} diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 15f6a4cd37..2e74ab760c 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -1,9 +1,7 @@ -#![allow(clippy::arithmetic_side_effects)] - use std::{num::NonZeroUsize, ops::Range, sync::Arc}; use educe::Educe; -use safe_arith::SafeArith; +use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode, four_byte_option_impl}; use ssz_derive::{Decode, Encode}; @@ -64,6 +62,9 @@ fn compare_shuffling_positions(xs: &Vec<NonZeroUsizeOption>, ys: &Vec<NonZeroUsi impl CommitteeCache { /// Return a new, fully initialized cache. /// + /// The epoch must be within the range that the state can service: historic epochs with + /// available randao data, up to `current_epoch + 1` (the "next" epoch). + /// /// Spec v0.12.1 pub fn initialized<E: EthSpec>( state: &BeaconState<E>, @@ -79,10 +80,48 @@ impl CommitteeCache { .saturating_sub(spec.min_seed_lookahead) .saturating_sub(1u64); - if reqd_randao_epoch < state.min_randao_epoch() || epoch > state.current_epoch() + 1 { + if reqd_randao_epoch < state.min_randao_epoch() + || epoch + > state + .current_epoch() + .safe_add(1u64) + .map_err(BeaconStateError::ArithError)? + { return Err(BeaconStateError::EpochOutOfBounds); } + Self::initialized_unchecked(state, epoch, spec) + } + + /// Return a new, fully initialized cache for a lookahead epoch. + /// + /// Like [`initialized`](Self::initialized), but allows epochs beyond `current_epoch + 1`. + /// The only bound enforced is that the required randao seed is available in the state. + /// + /// This is used by PTC window computation, which needs committee shufflings for + /// `current_epoch + 1 + MIN_SEED_LOOKAHEAD`. + pub fn initialized_for_lookahead<E: EthSpec>( + state: &BeaconState<E>, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result<Arc<CommitteeCache>, BeaconStateError> { + let reqd_randao_epoch = epoch + .saturating_sub(spec.min_seed_lookahead) + .saturating_sub(1u64); + + if reqd_randao_epoch < state.min_randao_epoch() { + return Err(BeaconStateError::EpochOutOfBounds); + } + + Self::initialized_unchecked(state, epoch, spec) + } + + /// Core committee cache construction. Callers are responsible for bounds-checking `epoch`. + fn initialized_unchecked<E: EthSpec>( + state: &BeaconState<E>, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result<Arc<CommitteeCache>, BeaconStateError> { // May cause divide-by-zero errors. if E::slots_per_epoch() == 0 { return Err(BeaconStateError::ZeroSlotsPerEpoch); @@ -100,7 +139,8 @@ impl CommitteeCache { } let committees_per_slot = - E::get_committee_count_per_slot(active_validator_indices.len(), spec)? as u64; + E::get_committee_count_per_slot(active_validator_indices.len(), spec) + .map_err(BeaconStateError::ArithError)? as u64; let seed = state.get_seed(epoch, Domain::BeaconAttester, spec)?; @@ -117,7 +157,7 @@ impl CommitteeCache { *shuffling_positions .get_mut(v) .ok_or(BeaconStateError::ShuffleIndexOutOfBounds(v))? = - NonZeroUsize::new(i + 1).into(); + NonZeroUsize::new(i.safe_add(1).map_err(BeaconStateError::ArithError)?).into(); } Ok(Arc::new(CommitteeCache { @@ -176,8 +216,9 @@ impl CommitteeCache { self.slots_per_epoch as usize, self.committees_per_slot as usize, index as usize, - ); - let committee = self.compute_committee(committee_index)?; + ) + .ok()?; + let committee = self.compute_committee(committee_index).ok()??; Some(BeaconCommittee { slot, @@ -211,8 +252,9 @@ impl CommitteeCache { .initialized_epoch .ok_or(BeaconStateError::CommitteeCacheUninitialized(None))?; + let capacity = self.epoch_committee_count()?; initialized_epoch.slot_iter(self.slots_per_epoch).try_fold( - Vec::with_capacity(self.epoch_committee_count()), + Vec::with_capacity(capacity), |mut vec, slot| { vec.append(&mut self.get_beacon_committees_at_slot(slot)?); Ok(vec) @@ -224,43 +266,53 @@ impl CommitteeCache { /// /// Returns `None` if the `validator_index` does not exist, does not have duties or `Self` is /// non-initialized. - pub fn get_attestation_duties(&self, validator_index: usize) -> Option<AttestationDuty> { - let i = self.shuffled_position(validator_index)?; + pub fn get_attestation_duties( + &self, + validator_index: usize, + ) -> Result<Option<AttestationDuty>, ArithError> { + let Some(i) = self.shuffled_position(validator_index) else { + return Ok(None); + }; - (0..self.epoch_committee_count()) - .map(|nth_committee| (nth_committee, self.compute_committee_range(nth_committee))) - .find(|(_, range)| { - if let Some(range) = range { - range.start <= i && range.end > i - } else { - false - } - }) - .and_then(|(nth_committee, range)| { - let (slot, index) = self.convert_to_slot_and_index(nth_committee as u64)?; - let range = range?; - let committee_position = i - range.start; - let committee_len = range.end - range.start; + for nth_committee in 0..self.epoch_committee_count()? { + let Some(range) = self.compute_committee_range(nth_committee)? else { + continue; + }; - Some(AttestationDuty { + if range.start <= i && range.end > i { + let Some((slot, index)) = self.convert_to_slot_and_index(nth_committee as u64)? + else { + return Ok(None); + }; + + let committee_position = i.safe_sub(range.start)?; + let committee_len = range.end.safe_sub(range.start)?; + + return Ok(Some(AttestationDuty { slot, index, committee_position, committee_len, committees_at_slot: self.committees_per_slot(), - }) - }) + })); + } + } + + Ok(None) } /// Convert an index addressing the list of all epoch committees into a slot and per-slot index. fn convert_to_slot_and_index( &self, global_committee_index: u64, - ) -> Option<(Slot, CommitteeIndex)> { - let epoch_start_slot = self.initialized_epoch?.start_slot(self.slots_per_epoch); - let slot_offset = global_committee_index / self.committees_per_slot; - let index = global_committee_index % self.committees_per_slot; - Some((epoch_start_slot.safe_add(slot_offset).ok()?, index)) + ) -> Result<Option<(Slot, CommitteeIndex)>, ArithError> { + let Some(epoch) = self.initialized_epoch else { + return Ok(None); + }; + let epoch_start_slot = epoch.start_slot(self.slots_per_epoch); + let slot_offset = global_committee_index.safe_div(self.committees_per_slot)?; + let index = global_committee_index.safe_rem(self.committees_per_slot)?; + Ok(Some((epoch_start_slot.safe_add(slot_offset)?, index))) } /// Returns the number of active validators in the initialized epoch. @@ -277,11 +329,8 @@ impl CommitteeCache { /// Always returns `usize::default()` for a non-initialized epoch. /// /// Spec v0.12.1 - pub fn epoch_committee_count(&self) -> usize { - epoch_committee_count( - self.committees_per_slot as usize, - self.slots_per_epoch as usize, - ) + pub fn epoch_committee_count(&self) -> Result<usize, ArithError> { + (self.committees_per_slot as usize).safe_mul(self.slots_per_epoch as usize) } /// Returns the number of committees per slot for this cache's epoch. @@ -292,19 +341,23 @@ impl CommitteeCache { /// Returns a slice of `self.shuffling` that represents the `index`'th committee in the epoch. /// /// Spec v0.12.1 - fn compute_committee(&self, index: usize) -> Option<&[usize]> { - self.shuffling.get(self.compute_committee_range(index)?) + fn compute_committee(&self, index: usize) -> Result<Option<&[usize]>, ArithError> { + if let Some(range) = self.compute_committee_range(index)? { + Ok(self.shuffling.get(range)) + } else { + Ok(None) + } } /// Returns a range of `self.shuffling` that represents the `index`'th committee in the epoch. /// - /// To avoid a divide-by-zero, returns `None` if `self.committee_count` is zero. + /// To avoid a divide-by-zero, returns `Ok(None)` if `self.committee_count` is zero. /// - /// Will also return `None` if the index is out of bounds. + /// Will also return `Ok(None)` if the index is out of bounds. /// /// Spec v0.12.1 - fn compute_committee_range(&self, index: usize) -> Option<Range<usize>> { - compute_committee_range_in_epoch(self.epoch_committee_count(), index, self.shuffling.len()) + fn compute_committee_range(&self, index: usize) -> Result<Option<Range<usize>>, ArithError> { + compute_committee_range_in_epoch(self.epoch_committee_count()?, index, self.shuffling.len()) } /// Returns the index of some validator in `self.shuffling`. @@ -328,8 +381,10 @@ pub fn compute_committee_index_in_epoch( slots_per_epoch: usize, committees_per_slot: usize, committee_index: usize, -) -> usize { - (slot.as_usize() % slots_per_epoch) * committees_per_slot + committee_index +) -> Result<usize, ArithError> { + (slot.as_usize().safe_rem(slots_per_epoch)?) + .safe_mul(committees_per_slot)? + .safe_add(committee_index) } /// Computes the range for slicing the shuffled indices to determine the members of a committee. @@ -340,20 +395,16 @@ pub fn compute_committee_range_in_epoch( epoch_committee_count: usize, index_in_epoch: usize, shuffling_len: usize, -) -> Option<Range<usize>> { +) -> Result<Option<Range<usize>>, ArithError> { if epoch_committee_count == 0 || index_in_epoch >= epoch_committee_count { - return None; + return Ok(None); } - let start = (shuffling_len * index_in_epoch) / epoch_committee_count; - let end = (shuffling_len * (index_in_epoch + 1)) / epoch_committee_count; + let start = (shuffling_len.safe_mul(index_in_epoch))?.safe_div(epoch_committee_count)?; + let end = + (shuffling_len.safe_mul(index_in_epoch.safe_add(1)?))?.safe_div(epoch_committee_count)?; - Some(start..end) -} - -/// Returns the total number of committees in an epoch. -pub fn epoch_committee_count(committees_per_slot: usize, slots_per_epoch: usize) -> usize { - committees_per_slot * slots_per_epoch + Ok(Some(start..end)) } /// Returns a list of all `validators` indices where the validator is active at the given diff --git a/consensus/types/src/state/mod.rs b/consensus/types/src/state/mod.rs index 770dc182fe..c833fadb64 100644 --- a/consensus/types/src/state/mod.rs +++ b/consensus/types/src/state/mod.rs @@ -18,11 +18,12 @@ pub use balance::Balance; pub use beacon_state::{ BeaconState, BeaconStateAltair, BeaconStateBase, BeaconStateBellatrix, BeaconStateCapella, BeaconStateDeneb, BeaconStateEip7805, BeaconStateElectra, BeaconStateError, BeaconStateFulu, - BeaconStateGloas, BeaconStateHash, BeaconStateRef, CACHED_EPOCHS, + BeaconStateGloas, BeaconStateHash, BeaconStateRef, CACHED_EPOCHS, DEFAULT_PRE_ELECTRA_WS_PERIOD, + Validators, }; pub use committee_cache::{ CommitteeCache, compute_committee_index_in_epoch, compute_committee_range_in_epoch, - epoch_committee_count, get_active_validator_indices, + get_active_validator_indices, }; pub use epoch_cache::{EpochCache, EpochCacheError, EpochCacheKey}; pub use exit_cache::ExitCache; diff --git a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs index cf7b5df891..2a38b5be1f 100644 --- a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs +++ b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs @@ -34,11 +34,8 @@ pub fn generate_rand_block_and_blobs<E: EthSpec>( .blob_kzg_commitments_mut() .expect("kzg commitment expected from Deneb") = commitments.clone(); - for (index, ((blob, kzg_commitment), kzg_proof)) in blobs - .into_iter() - .zip(commitments.into_iter()) - .zip(proofs.into_iter()) - .enumerate() + for (index, ((blob, kzg_commitment), kzg_proof)) in + blobs.into_iter().zip(commitments).zip(proofs).enumerate() { blob_sidecars.push(BlobSidecar { index: index as u64, @@ -100,20 +97,8 @@ mod test { .. } = blob_sidecars.pop().unwrap(); - // Compute the commitments inclusion proof and use it for building blob sidecar. - let (signed_block_header, kzg_commitments_inclusion_proof) = block - .signed_block_header_and_kzg_commitments_proof() - .unwrap(); - - let blob_sidecar = BlobSidecar::new_with_existing_proof( - index as usize, - blob, - &block, - signed_block_header, - &kzg_commitments_inclusion_proof, - kzg_proof, - ) - .unwrap(); + let blob_sidecar = + BlobSidecar::new_with_existing_proof(index as usize, blob, &block, kzg_proof).unwrap(); assert!(blob_sidecar.verify_blob_sidecar_inclusion_proof()); } diff --git a/consensus/types/src/validator/mod.rs b/consensus/types/src/validator/mod.rs index 8a67407821..23f7a2a0e1 100644 --- a/consensus/types/src/validator/mod.rs +++ b/consensus/types/src/validator/mod.rs @@ -4,6 +4,8 @@ mod validator_registration_data; mod validator_subscription; pub use proposer_preparation_data::ProposerPreparationData; -pub use validator::{Validator, is_compounding_withdrawal_credential}; +pub use validator::{ + Validator, is_builder_withdrawal_credential, is_compounding_withdrawal_credential, +}; pub use validator_registration_data::{SignedValidatorRegistrationData, ValidatorRegistrationData}; pub use validator_subscription::ValidatorSubscription; diff --git a/consensus/types/src/validator/validator.rs b/consensus/types/src/validator/validator.rs index 7898ab9073..5c5bfc761f 100644 --- a/consensus/types/src/validator/validator.rs +++ b/consensus/types/src/validator/validator.rs @@ -319,6 +319,14 @@ pub fn is_compounding_withdrawal_credential( .unwrap_or(false) } +pub fn is_builder_withdrawal_credential(withdrawal_credentials: Hash256, spec: &ChainSpec) -> bool { + withdrawal_credentials + .as_slice() + .first() + .map(|prefix_byte| *prefix_byte == spec.builder_withdrawal_prefix_byte) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/consensus/types/src/validator/validator_registration_data.rs b/consensus/types/src/validator/validator_registration_data.rs index a0a1df7dc5..df2293cbae 100644 --- a/consensus/types/src/validator/validator_registration_data.rs +++ b/consensus/types/src/validator/validator_registration_data.rs @@ -31,7 +31,7 @@ impl SignedValidatorRegistrationData { .pubkey .decompress() .map(|pubkey| { - let domain = spec.get_builder_domain(); + let domain = spec.get_builder_application_domain(); let message = self.message.signing_root(domain); self.signature.verify(&pubkey, message) }) diff --git a/consensus/types/src/withdrawal/expected_withdrawals.rs b/consensus/types/src/withdrawal/expected_withdrawals.rs new file mode 100644 index 0000000000..f9809e6e73 --- /dev/null +++ b/consensus/types/src/withdrawal/expected_withdrawals.rs @@ -0,0 +1,29 @@ +use crate::{EthSpec, Withdrawals}; +use superstruct::superstruct; + +#[superstruct( + variants(Capella, Electra, Gloas), + variant_attributes(derive(Debug, PartialEq, Clone)) +)] +#[derive(Debug, PartialEq, Clone)] +pub struct ExpectedWithdrawals<E: EthSpec> { + pub withdrawals: Withdrawals<E>, + #[superstruct(only(Gloas), partial_getter(copy))] + pub processed_builder_withdrawals_count: u64, + #[superstruct(only(Electra, Gloas), partial_getter(copy))] + pub processed_partial_withdrawals_count: u64, + #[superstruct(only(Gloas), partial_getter(copy))] + pub processed_builders_sweep_count: u64, + #[superstruct(getter(copy))] + pub processed_sweep_withdrawals_count: u64, +} + +impl<E: EthSpec> From<ExpectedWithdrawals<E>> for Withdrawals<E> { + fn from(expected_withdrawals: ExpectedWithdrawals<E>) -> Withdrawals<E> { + match expected_withdrawals { + ExpectedWithdrawals::Capella(ew) => ew.withdrawals, + ExpectedWithdrawals::Electra(ew) => ew.withdrawals, + ExpectedWithdrawals::Gloas(ew) => ew.withdrawals, + } + } +} diff --git a/consensus/types/src/withdrawal/mod.rs b/consensus/types/src/withdrawal/mod.rs index bac80d00be..fbe7351754 100644 --- a/consensus/types/src/withdrawal/mod.rs +++ b/consensus/types/src/withdrawal/mod.rs @@ -1,8 +1,13 @@ +mod expected_withdrawals; mod pending_partial_withdrawal; mod withdrawal; mod withdrawal_credentials; mod withdrawal_request; +pub use expected_withdrawals::{ + ExpectedWithdrawals, ExpectedWithdrawalsCapella, ExpectedWithdrawalsElectra, + ExpectedWithdrawalsGloas, +}; pub use pending_partial_withdrawal::PendingPartialWithdrawal; pub use withdrawal::{Withdrawal, Withdrawals}; pub use withdrawal_credentials::WithdrawalCredentials; diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 751ef05d29..5c1962276f 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -21,6 +21,7 @@ fn get_harness<E: EthSpec>(validator_count: usize) -> BeaconChainHarness<Ephemer .default_spec() .keypairs(KEYPAIRS[0..validator_count].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); harness.advance_slot(); harness @@ -33,9 +34,9 @@ fn default_values() { assert!(!cache.is_initialized_at(Epoch::new(0))); assert!(&cache.active_validator_indices().is_empty()); assert_eq!(cache.get_beacon_committee(Slot::new(0), 0), None); - assert_eq!(cache.get_attestation_duties(0), None); + assert_eq!(cache.get_attestation_duties(0), Ok(None)); assert_eq!(cache.active_validator_count(), 0); - assert_eq!(cache.epoch_committee_count(), 0); + assert_eq!(cache.epoch_committee_count(), Ok(0)); assert!(cache.get_beacon_committees_at_slot(Slot::new(0)).is_err()); } diff --git a/consensus/types/tests/state.rs b/consensus/types/tests/state.rs index 63ab3b8084..5e223092cf 100644 --- a/consensus/types/tests/state.rs +++ b/consensus/types/tests/state.rs @@ -28,6 +28,7 @@ async fn get_harness<E: EthSpec>( .default_spec() .keypairs(KEYPAIRS[0..validator_count].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); let skip_to_slot = slot - SLOT_OFFSET; diff --git a/crypto/bls/Cargo.toml b/crypto/bls/Cargo.toml index 4661288679..ac04e1fecf 100644 --- a/crypto/bls/Cargo.toml +++ b/crypto/bls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Paul Hauner <paul@paulhauner.com>"] edition = { workspace = true } [features] -arbitrary = [] +arbitrary = ["dep:arbitrary"] default = ["supranational"] fake_crypto = [] supranational = ["blst"] @@ -14,7 +14,7 @@ supranational-force-adx = ["supranational", "blst/force-adx"] [dependencies] alloy-primitives = { workspace = true } -arbitrary = { workspace = true } +arbitrary = { workspace = true, optional = true } blst = { version = "0.3.3", optional = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } diff --git a/crypto/bls/src/impls/fake_crypto.rs b/crypto/bls/src/impls/fake_crypto.rs index e7eee05077..5fe0c3baab 100644 --- a/crypto/bls/src/impls/fake_crypto.rs +++ b/crypto/bls/src/impls/fake_crypto.rs @@ -49,7 +49,9 @@ impl TPublicKey for PublicKey { } fn serialize_uncompressed(&self) -> [u8; PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN] { - panic!("fake_crypto does not support uncompressed keys") + let mut bytes = [0; PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN]; + bytes[0..PUBLIC_KEY_BYTES_LEN].copy_from_slice(&self.0); + bytes } fn deserialize(bytes: &[u8]) -> Result<Self, Error> { @@ -58,8 +60,17 @@ impl TPublicKey for PublicKey { Ok(pubkey) } - fn deserialize_uncompressed(_: &[u8]) -> Result<Self, Error> { - panic!("fake_crypto does not support uncompressed keys") + fn deserialize_uncompressed(bytes: &[u8]) -> Result<Self, Error> { + if bytes.len() == PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN { + let mut pubkey = Self([0; PUBLIC_KEY_BYTES_LEN]); + pubkey.0.copy_from_slice(&bytes[0..PUBLIC_KEY_BYTES_LEN]); + Ok(pubkey) + } else { + Err(Error::InvalidByteLength { + got: bytes.len(), + expected: PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN, + }) + } } } @@ -97,7 +108,7 @@ pub struct Signature([u8; SIGNATURE_BYTES_LEN]); impl Signature { fn infinity() -> Self { - Self([0; SIGNATURE_BYTES_LEN]) + Self(INFINITY_SIGNATURE) } } @@ -213,7 +224,11 @@ impl TSecretKey<Signature, PublicKey> for SecretKey { } fn public_key(&self) -> PublicKey { - PublicKey::infinity() + let mut bytes = [0; PUBLIC_KEY_BYTES_LEN]; + bytes[0] = 0x01; + let to_copy = std::cmp::min(self.0.len(), bytes.len() - 1); + bytes[1..1 + to_copy].copy_from_slice(&self.0[..to_copy]); + PublicKey(bytes) } fn sign(&self, _msg: Hash256) -> Signature { diff --git a/crypto/eth2_keystore/Cargo.toml b/crypto/eth2_keystore/Cargo.toml index 290a10adc9..4fde662f5c 100644 --- a/crypto/eth2_keystore/Cargo.toml +++ b/crypto/eth2_keystore/Cargo.toml @@ -7,14 +7,16 @@ autotests = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -aes = { version = "0.7", features = ["ctr"] } +aes = "0.8" bls = { workspace = true } +cipher = "0.4" +ctr = "0.9" eth2_key_derivation = { workspace = true } hex = { workspace = true } -hmac = "0.11.0" -pbkdf2 = { version = "0.8.0", default-features = false } +hmac = "0.12.0" +pbkdf2 = { version = "0.12.0", default-features = false } rand = { workspace = true } -scrypt = { version = "0.7.0", default-features = false } +scrypt = { version = "0.11.0", default-features = false } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } diff --git a/crypto/eth2_keystore/src/json_keystore/kdf_module.rs b/crypto/eth2_keystore/src/json_keystore/kdf_module.rs index 2086ac7b21..1702d8621c 100644 --- a/crypto/eth2_keystore/src/json_keystore/kdf_module.rs +++ b/crypto/eth2_keystore/src/json_keystore/kdf_module.rs @@ -5,9 +5,10 @@ use super::hex_bytes::HexBytes; use crate::DKLEN; -use hmac::{Hmac, Mac, NewMac}; +use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; +use sha2::digest::KeyInit; /// KDF module representation. #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] @@ -67,7 +68,7 @@ pub enum Prf { impl Prf { pub fn mac(&self, password: &[u8]) -> impl Mac { match &self { - Prf::HmacSha256 => Hmac::<Sha256>::new_from_slice(password) + Prf::HmacSha256 => <Hmac<Sha256> as KeyInit>::new_from_slice(password) .expect("Could not derive HMAC using SHA256."), } } diff --git a/crypto/eth2_keystore/src/keystore.rs b/crypto/eth2_keystore/src/keystore.rs index b31e32eb4a..e6fc59d462 100644 --- a/crypto/eth2_keystore/src/keystore.rs +++ b/crypto/eth2_keystore/src/keystore.rs @@ -7,10 +7,11 @@ use crate::json_keystore::{ Aes128Ctr, ChecksumModule, Cipher, CipherModule, Crypto, EmptyMap, EmptyString, JsonKeystore, Kdf, KdfModule, Scrypt, Sha256Checksum, Version, }; -use aes::Aes128Ctr as AesCtr; -use aes::cipher::generic_array::GenericArray; -use aes::cipher::{NewCipher, StreamCipher}; +use aes::Aes128; use bls::{Keypair, PublicKey, SecretKey, ZeroizeHash}; +use cipher::generic_array::GenericArray; +use cipher::{KeyIvInit, StreamCipher}; +use ctr::Ctr64BE; use eth2_key_derivation::PlainText; use hmac::Hmac; use pbkdf2::pbkdf2; @@ -350,7 +351,7 @@ pub fn encrypt( // AES Encrypt let key = GenericArray::from_slice(&derived_key.as_bytes()[0..16]); let nonce = GenericArray::from_slice(params.iv.as_bytes()); - let mut cipher = AesCtr::new(key, nonce); + let mut cipher = Ctr64BE::<Aes128>::new(key, nonce); cipher.apply_keystream(&mut cipher_text); } }; @@ -396,7 +397,7 @@ pub fn decrypt(password: &[u8], crypto: &Crypto) -> Result<PlainText, Error> { // AES Decrypt let key = GenericArray::from_slice(&derived_key.as_bytes()[0..16]); let nonce = GenericArray::from_slice(params.iv.as_bytes()); - let mut cipher = AesCtr::new(key, nonce); + let mut cipher = Ctr64BE::<Aes128>::new(key, nonce); cipher.apply_keystream(plain_text.as_mut_bytes()); } }; @@ -444,13 +445,15 @@ fn derive_key(password: &[u8], kdf: &Kdf) -> Result<DerivedKey, Error> { params.salt.as_bytes(), params.c, dk.as_mut_bytes(), - ); + ) + // `pbkdf2` accepts keys of any size so this error should never occur in practice. + .map_err(|_| Error::InvalidPassword)?; } Kdf::Scrypt(params) => { scrypt( password, params.salt.as_bytes(), - &ScryptParams::new(log2_int(params.n) as u8, params.r, params.p) + &ScryptParams::new(log2_int(params.n) as u8, params.r, params.p, DKLEN as usize) .map_err(Error::ScryptInvalidParams)?, dk.as_mut_bytes(), ) diff --git a/crypto/eth2_wallet/Cargo.toml b/crypto/eth2_wallet/Cargo.toml index 0d454016a6..fcbcbe6ac8 100644 --- a/crypto/eth2_wallet/Cargo.toml +++ b/crypto/eth2_wallet/Cargo.toml @@ -13,7 +13,7 @@ rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } -tiny-bip39 = "1" +tiny-bip39 = "2" uuid = { workspace = true } [dev-dependencies] diff --git a/crypto/kzg/Cargo.toml b/crypto/kzg/Cargo.toml index 5a36eb74f7..19f39a182b 100644 --- a/crypto/kzg/Cargo.toml +++ b/crypto/kzg/Cargo.toml @@ -5,9 +5,13 @@ authors = ["Pawan Dhananjay <pawandhananjay@gmail.com>"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = [] +arbitrary = ["dep:arbitrary"] +fake_crypto = [] + [dependencies] -arbitrary = { workspace = true } -c-kzg = { workspace = true } +arbitrary = { workspace = true, optional = true } educe = { workspace = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -23,7 +27,6 @@ tree_hash = { workspace = true } [dev-dependencies] criterion = { workspace = true } -serde_json = { workspace = true } [[bench]] name = "benchmark" diff --git a/crypto/kzg/benches/benchmark.rs b/crypto/kzg/benches/benchmark.rs index 432d84654a..d5d5596211 100644 --- a/crypto/kzg/benches/benchmark.rs +++ b/crypto/kzg/benches/benchmark.rs @@ -1,6 +1,5 @@ -use c_kzg::KzgSettings; use criterion::{criterion_group, criterion_main, Criterion}; -use kzg::{trusted_setup::get_trusted_setup, TrustedSetup, NO_PRECOMPUTE}; +use kzg::trusted_setup::get_trusted_setup; use rust_eth_kzg::{DASContext, TrustedSetup as PeerDASTrustedSetup}; pub fn bench_init_context(c: &mut Criterion) { @@ -20,21 +19,6 @@ pub fn bench_init_context(c: &mut Criterion) { ) }) }); - c.bench_function("Initialize context c-kzg (4844)", |b| { - b.iter(|| { - let trusted_setup: TrustedSetup = - serde_json::from_reader(trusted_setup_bytes.as_slice()) - .map_err(|e| format!("Unable to read trusted setup file: {}", e)) - .expect("should have trusted setup"); - KzgSettings::load_trusted_setup( - &trusted_setup.g1_monomial(), - &trusted_setup.g1_lagrange(), - &trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - ) - .unwrap() - }) - }); } criterion_group!(benches, bench_init_context); diff --git a/crypto/kzg/src/kzg_commitment.rs b/crypto/kzg/src/kzg_commitment.rs index 5a5e689429..d8ef4b36cf 100644 --- a/crypto/kzg/src/kzg_commitment.rs +++ b/crypto/kzg/src/kzg_commitment.rs @@ -1,4 +1,4 @@ -use c_kzg::BYTES_PER_COMMITMENT; +use crate::{Bytes48, BYTES_PER_COMMITMENT}; use educe::Educe; use ethereum_hashing::hash_fixed; use serde::de::{Deserialize, Deserializer}; @@ -14,7 +14,7 @@ pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01; #[derive(Educe, Clone, Copy, Encode, Decode)] #[educe(PartialEq, Eq, Hash)] #[ssz(struct_behaviour = "transparent")] -pub struct KzgCommitment(pub [u8; c_kzg::BYTES_PER_COMMITMENT]); +pub struct KzgCommitment(pub [u8; BYTES_PER_COMMITMENT]); impl KzgCommitment { pub fn calculate_versioned_hash(&self) -> Hash256 { @@ -24,13 +24,13 @@ impl KzgCommitment { } pub fn empty_for_testing() -> Self { - KzgCommitment([0; c_kzg::BYTES_PER_COMMITMENT]) + KzgCommitment([0; BYTES_PER_COMMITMENT]) } } -impl From<KzgCommitment> for c_kzg::Bytes48 { +impl From<KzgCommitment> for Bytes48 { fn from(value: KzgCommitment) -> Self { - value.0.into() + value.0 } } @@ -114,6 +114,7 @@ impl Debug for KzgCommitment { } } +#[cfg(feature = "arbitrary")] impl arbitrary::Arbitrary<'_> for KzgCommitment { fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> { let mut bytes = [0u8; BYTES_PER_COMMITMENT]; diff --git a/crypto/kzg/src/kzg_proof.rs b/crypto/kzg/src/kzg_proof.rs index 5a83466d0c..e0867520eb 100644 --- a/crypto/kzg/src/kzg_proof.rs +++ b/crypto/kzg/src/kzg_proof.rs @@ -1,4 +1,4 @@ -use c_kzg::BYTES_PER_PROOF; +use crate::BYTES_PER_PROOF; use serde::de::{Deserialize, Deserializer}; use serde::ser::{Serialize, Serializer}; use ssz_derive::{Decode, Encode}; @@ -11,12 +11,6 @@ use tree_hash::{PackedEncoding, TreeHash}; #[ssz(struct_behaviour = "transparent")] pub struct KzgProof(pub [u8; BYTES_PER_PROOF]); -impl From<KzgProof> for c_kzg::Bytes48 { - fn from(value: KzgProof) -> Self { - value.0.into() - } -} - impl KzgProof { /// Creates a valid proof using `G1_POINT_AT_INFINITY`. pub fn empty() -> Self { @@ -110,6 +104,7 @@ impl Debug for KzgProof { } } +#[cfg(feature = "arbitrary")] impl arbitrary::Arbitrary<'_> for KzgProof { fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> { let mut bytes = [0u8; BYTES_PER_PROOF]; diff --git a/crypto/kzg/src/lib.rs b/crypto/kzg/src/lib.rs index 0fe95b7723..6ee352b0db 100644 --- a/crypto/kzg/src/lib.rs +++ b/crypto/kzg/src/lib.rs @@ -12,11 +12,12 @@ pub use crate::{ trusted_setup::TrustedSetup, }; -pub use c_kzg::{ - Blob, Bytes32, Bytes48, KzgSettings, BYTES_PER_BLOB, BYTES_PER_COMMITMENT, - BYTES_PER_FIELD_ELEMENT, BYTES_PER_PROOF, FIELD_ELEMENTS_PER_BLOB, +pub use rust_eth_kzg::constants::{ + BYTES_PER_BLOB, BYTES_PER_COMMITMENT, BYTES_PER_FIELD_ELEMENT, FIELD_ELEMENTS_PER_BLOB, }; +pub const BYTES_PER_PROOF: usize = 48; + use crate::trusted_setup::load_trusted_setup; use rayon::prelude::*; pub use rust_eth_kzg::{ @@ -25,13 +26,6 @@ pub use rust_eth_kzg::{ }; use tracing::{instrument, Span}; -/// Disables the fixed-base multi-scalar multiplication optimization for computing -/// cell KZG proofs, because `rust-eth-kzg` already handles the precomputation. -/// -/// Details about `precompute` parameter can be found here: -/// <https://github.com/ethereum/c-kzg-4844/pull/545/files> -pub const NO_PRECOMPUTE: u64 = 0; - // Note: Both `NUMBER_OF_COLUMNS` and `CELLS_PER_EXT_BLOB` are preset values - however this // is a constant in the KZG library - be aware that overriding `NUMBER_OF_COLUMNS` will break KZG // operations. @@ -39,14 +33,15 @@ pub type CellsAndKzgProofs = ([Cell; CELLS_PER_EXT_BLOB], [KzgProof; CELLS_PER_E pub type KzgBlobRef<'a> = &'a [u8; BYTES_PER_BLOB]; +type Bytes32 = [u8; 32]; +type Bytes48 = [u8; 48]; + #[derive(Debug)] pub enum Error { /// An error from initialising the trusted setup. TrustedSetupError(String), - /// An error from the underlying kzg library. - Kzg(c_kzg::Error), - /// A prover/verifier error from the rust-eth-kzg library. - PeerDASKZG(rust_eth_kzg::Error), + /// An error from the rust-eth-kzg library. + Kzg(rust_eth_kzg::Error), /// The kzg verification failed KzgVerificationFailed, /// Misc indexing error @@ -57,38 +52,29 @@ pub enum Error { DASContextUninitialized, } -impl From<c_kzg::Error> for Error { - fn from(value: c_kzg::Error) -> Self { +impl From<rust_eth_kzg::Error> for Error { + fn from(value: rust_eth_kzg::Error) -> Self { Error::Kzg(value) } } -/// A wrapper over a kzg library that holds the trusted setup parameters. +/// A wrapper over the rust-eth-kzg library that holds the trusted setup parameters. #[derive(Debug)] pub struct Kzg { - trusted_setup: KzgSettings, context: DASContext, } impl Kzg { pub fn new_from_trusted_setup_no_precomp(trusted_setup: &[u8]) -> Result<Self, Error> { - let (ckzg_trusted_setup, rkzg_trusted_setup) = load_trusted_setup(trusted_setup)?; + let rkzg_trusted_setup = load_trusted_setup(trusted_setup)?; let context = DASContext::new(&rkzg_trusted_setup, rust_eth_kzg::UsePrecomp::No); - Ok(Self { - trusted_setup: KzgSettings::load_trusted_setup( - &ckzg_trusted_setup.g1_monomial(), - &ckzg_trusted_setup.g1_lagrange(), - &ckzg_trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - )?, - context, - }) + Ok(Self { context }) } /// Load the kzg trusted setup parameters from a vec of G1 and G2 points. pub fn new_from_trusted_setup(trusted_setup: &[u8]) -> Result<Self, Error> { - let (ckzg_trusted_setup, rkzg_trusted_setup) = load_trusted_setup(trusted_setup)?; + let rkzg_trusted_setup = load_trusted_setup(trusted_setup)?; // It's not recommended to change the config parameter for precomputation as storage // grows exponentially, but the speedup is exponential - after a while the speedup @@ -100,15 +86,7 @@ impl Kzg { }, ); - Ok(Self { - trusted_setup: KzgSettings::load_trusted_setup( - &ckzg_trusted_setup.g1_monomial(), - &ckzg_trusted_setup.g1_lagrange(), - &ckzg_trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - )?, - context, - }) + Ok(Self { context }) } fn context(&self) -> &DASContext { @@ -118,31 +96,35 @@ impl Kzg { /// Compute the kzg proof given a blob and its kzg commitment. pub fn compute_blob_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, kzg_commitment: KzgCommitment, ) -> Result<KzgProof, Error> { - self.trusted_setup - .compute_blob_kzg_proof(blob, &kzg_commitment.into()) - .map(|proof| KzgProof(proof.to_bytes().into_inner())) - .map_err(Into::into) + let proof = self + .context() + .compute_blob_kzg_proof(blob, &kzg_commitment.0) + .map_err(Error::Kzg)?; + Ok(KzgProof(proof)) } /// Verify a kzg proof given the blob, kzg commitment and kzg proof. pub fn verify_blob_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, kzg_commitment: KzgCommitment, kzg_proof: KzgProof, ) -> Result<(), Error> { - if !self.trusted_setup.verify_blob_kzg_proof( - blob, - &kzg_commitment.into(), - &kzg_proof.into(), - )? { - Err(Error::KzgVerificationFailed) - } else { - Ok(()) + if cfg!(feature = "fake_crypto") { + return Ok(()); } + self.context() + .verify_blob_kzg_proof(blob, &kzg_commitment.0, &kzg_proof.0) + .map_err(|e| { + if e.is_proof_invalid() { + Error::KzgVerificationFailed + } else { + Error::Kzg(e) + } + }) } /// Verify a batch of blob commitment proof triplets. @@ -151,49 +133,48 @@ impl Kzg { /// TODO(pawan): test performance against a parallelized rayon impl. pub fn verify_blob_kzg_proof_batch( &self, - blobs: &[Blob], + blobs: &[KzgBlobRef<'_>], kzg_commitments: &[KzgCommitment], kzg_proofs: &[KzgProof], ) -> Result<(), Error> { - let commitments_bytes = kzg_commitments - .iter() - .map(|comm| Bytes48::from(*comm)) - .collect::<Vec<_>>(); - - let proofs_bytes = kzg_proofs - .iter() - .map(|proof| Bytes48::from(*proof)) - .collect::<Vec<_>>(); - - if !self.trusted_setup.verify_blob_kzg_proof_batch( - blobs, - &commitments_bytes, - &proofs_bytes, - )? { - Err(Error::KzgVerificationFailed) - } else { - Ok(()) + if cfg!(feature = "fake_crypto") { + return Ok(()); } + let blob_refs: Vec<&[u8; BYTES_PER_BLOB]> = blobs.to_vec(); + let commitment_refs: Vec<&[u8; 48]> = kzg_commitments.iter().map(|c| &c.0).collect(); + let proof_refs: Vec<&[u8; 48]> = kzg_proofs.iter().map(|p| &p.0).collect(); + + self.context() + .verify_blob_kzg_proof_batch(blob_refs, commitment_refs, proof_refs) + .map_err(|e| { + if e.is_proof_invalid() { + Error::KzgVerificationFailed + } else { + Error::Kzg(e) + } + }) } /// Converts a blob to a kzg commitment. - pub fn blob_to_kzg_commitment(&self, blob: &Blob) -> Result<KzgCommitment, Error> { - self.trusted_setup + pub fn blob_to_kzg_commitment(&self, blob: KzgBlobRef<'_>) -> Result<KzgCommitment, Error> { + let commitment = self + .context() .blob_to_kzg_commitment(blob) - .map(|commitment| KzgCommitment(commitment.to_bytes().into_inner())) - .map_err(Into::into) + .map_err(Error::Kzg)?; + Ok(KzgCommitment(commitment)) } /// Computes the kzg proof for a given `blob` and an evaluation point `z` pub fn compute_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, z: &Bytes32, ) -> Result<(KzgProof, Bytes32), Error> { - self.trusted_setup - .compute_kzg_proof(blob, z) - .map(|(proof, y)| (KzgProof(proof.to_bytes().into_inner()), y)) - .map_err(Into::into) + let (proof, y) = self + .context() + .compute_kzg_proof(blob, *z) + .map_err(Error::Kzg)?; + Ok((KzgProof(proof), y)) } /// Verifies a `kzg_proof` for a `kzg_commitment` that evaluating a polynomial at `z` results in `y` @@ -204,9 +185,17 @@ impl Kzg { y: &Bytes32, kzg_proof: KzgProof, ) -> Result<bool, Error> { - self.trusted_setup - .verify_kzg_proof(&kzg_commitment.into(), z, y, &kzg_proof.into()) - .map_err(Into::into) + if cfg!(feature = "fake_crypto") { + return Ok(true); + } + match self + .context() + .verify_kzg_proof(&kzg_commitment.0, *z, *y, &kzg_proof.0) + { + Ok(()) => Ok(true), + Err(e) if e.is_proof_invalid() => Ok(false), + Err(e) => Err(Error::Kzg(e)), + } } /// Computes the cells and associated proofs for a given `blob`. @@ -217,18 +206,15 @@ impl Kzg { let (cells, proofs) = self .context() .compute_cells_and_kzg_proofs(blob) - .map_err(Error::PeerDASKZG)?; + .map_err(Error::Kzg)?; - // Convert the proof type to a c-kzg proof type - let c_kzg_proof = proofs.map(KzgProof); - Ok((cells, c_kzg_proof)) + let kzg_proofs = proofs.map(KzgProof); + Ok((cells, kzg_proofs)) } /// Computes the cells for a given `blob`. pub fn compute_cells(&self, blob: KzgBlobRef<'_>) -> Result<[Cell; CELLS_PER_EXT_BLOB], Error> { - self.context() - .compute_cells(blob) - .map_err(Error::PeerDASKZG) + self.context().compute_cells(blob).map_err(Error::Kzg) } /// Verifies a batch of cell-proof-commitment triplets. @@ -240,6 +226,9 @@ impl Kzg { indices: Vec<CellIndex>, kzg_commitments: &[Bytes48], ) -> Result<(), (Option<u64>, Error)> { + if cfg!(feature = "fake_crypto") { + return Ok(()); + } let mut column_groups: HashMap<u64, Vec<(CellRef, Bytes48, Bytes48)>> = HashMap::new(); let expected_len = cells.len(); @@ -279,8 +268,8 @@ impl Kzg { for (cell, proof, commitment) in &column_data { cells.push(*cell); - proofs.push(proof.as_ref()); - commitments.push(commitment.as_ref()); + proofs.push(proof); + commitments.push(commitment); } // Create per-chunk tracing span for visualizing parallel processing. @@ -307,7 +296,7 @@ impl Kzg { Err(e) if e.is_proof_invalid() => { Err((Some(column_index), Error::KzgVerificationFailed)) } - Err(e) => Err((Some(column_index), Error::PeerDASKZG(e))), + Err(e) => Err((Some(column_index), Error::Kzg(e))), } }) .collect::<Result<Vec<()>, (Option<u64>, Error)>>()?; @@ -323,10 +312,9 @@ impl Kzg { let (cells, proofs) = self .context() .recover_cells_and_kzg_proofs(cell_ids.to_vec(), cells.to_vec()) - .map_err(Error::PeerDASKZG)?; + .map_err(Error::Kzg)?; - // Convert the proof type to a c-kzg proof type - let c_kzg_proof = proofs.map(KzgProof); - Ok((cells, c_kzg_proof)) + let kzg_proofs = proofs.map(KzgProof); + Ok((cells, kzg_proofs)) } } diff --git a/crypto/kzg/src/trusted_setup.rs b/crypto/kzg/src/trusted_setup.rs index 75884b8199..5c285b50f2 100644 --- a/crypto/kzg/src/trusted_setup.rs +++ b/crypto/kzg/src/trusted_setup.rs @@ -24,7 +24,7 @@ struct G1Point([u8; BYTES_PER_G1_POINT]); struct G2Point([u8; BYTES_PER_G2_POINT]); /// Contains the trusted setup parameters that are required to instantiate a -/// `c_kzg::KzgSettings` object. +/// `rust_eth_kzg::TrustedSetup` object. /// /// The serialize/deserialize implementations are written according to /// the format specified in the ethereum consensus specs trusted setup files. @@ -155,19 +155,9 @@ fn strip_prefix(s: &str) -> &str { } } -/// Loads the trusted setup from JSON. -/// -/// ## Note: -/// Currently we load both c-kzg and rust-eth-kzg trusted setup structs, because c-kzg is still being -/// used for 4844. Longer term we're planning to switch all KZG operations to the rust-eth-kzg -/// crate, and we'll be able to maintain a single trusted setup struct. -pub(crate) fn load_trusted_setup( - trusted_setup: &[u8], -) -> Result<(TrustedSetup, PeerDASTrustedSetup), Error> { - let ckzg_trusted_setup: TrustedSetup = serde_json::from_slice(trusted_setup) - .map_err(|e| Error::TrustedSetupError(format!("{e:?}")))?; +/// Loads the trusted setup from JSON bytes into a `rust_eth_kzg::TrustedSetup`. +pub(crate) fn load_trusted_setup(trusted_setup: &[u8]) -> Result<PeerDASTrustedSetup, Error> { let trusted_setup_json = std::str::from_utf8(trusted_setup) .map_err(|e| Error::TrustedSetupError(format!("{e:?}")))?; - let rkzg_trusted_setup = PeerDASTrustedSetup::from_json(trusted_setup_json); - Ok((ckzg_trusted_setup, rkzg_trusted_setup)) + Ok(PeerDASTrustedSetup::from_json(trusted_setup_json)) } diff --git a/deny.toml b/deny.toml index 398a173dfa..015f2ec88b 100644 --- a/deny.toml +++ b/deny.toml @@ -10,8 +10,17 @@ deny = [ { crate = "protobuf", reason = "use quick-protobuf instead" }, { crate = "derivative", reason = "use educe or derive_more instead" }, { crate = "ark-ff", reason = "present in Cargo.lock but not needed by Lighthouse" }, + { crate = "openssl", reason = "non-Rust dependency, use rustls instead" }, + { crate = "c-kzg", reason = "non-Rust dependency, use rust_eth_kzg instead" }, + { crate = "serde_yaml", reason = "deprecated, use yaml_serde instead" }, { crate = "strum", deny-multiple-versions = true, reason = "takes a long time to compile" }, - { crate = "reqwest", deny-multiple-versions = true, reason = "takes a long time to compile" } + { crate = "reqwest", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "aes", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "sha2", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "pbkdf2", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "scrypt", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "syn", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "uuid", deny-multiple-versions = true, reason = "dependency hygiene" }, ] [sources] diff --git a/lcli/Cargo.toml b/lcli/Cargo.toml index 43e361b60d..84525c05b9 100644 --- a/lcli/Cargo.toml +++ b/lcli/Cargo.toml @@ -35,7 +35,6 @@ network_utils = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } snap = { workspace = true } state_processing = { workspace = true } store = { workspace = true } @@ -44,6 +43,7 @@ tracing-subscriber = { workspace = true } tree_hash = { workspace = true } types = { workspace = true } validator_dir = { workspace = true } +yaml_serde = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dependencies] malloc_utils = { workspace = true, features = ["jemalloc"] } diff --git a/lcli/src/main.rs b/lcli/src/main.rs index a21dfd4386..63dd0f2c5b 100644 --- a/lcli/src/main.rs +++ b/lcli/src/main.rs @@ -492,10 +492,20 @@ fn main() { .long("jwt-output-path") .value_name("PATH") .action(ArgAction::Set) - .required(true) + .required_unless_present("jwt-secret-path") + .conflicts_with("jwt-secret-path") .help("Path to write the JWT secret.") .display_order(0) ) + .arg( + Arg::new("jwt-secret-path") + .long("jwt-secret-path") + .value_name("PATH") + .action(ArgAction::Set) + .help("Path to an existing hex-encoded JWT secret file. \ + When provided, this secret is used instead of the default.") + .display_order(0) + ) .arg( Arg::new("listen-address") .long("listen-address") diff --git a/lcli/src/mock_el.rs b/lcli/src/mock_el.rs index b54c136f43..dc913595e2 100644 --- a/lcli/src/mock_el.rs +++ b/lcli/src/mock_el.rs @@ -2,18 +2,16 @@ use clap::ArgMatches; use clap_utils::{parse_optional, parse_required}; use environment::Environment; use execution_layer::{ - auth::JwtKey, - test_utils::{ - Config, DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, MockExecutionConfig, MockServer, - }, + auth::{JwtKey, strip_prefix}, + test_utils::{Config, DEFAULT_JWT_SECRET, MockExecutionConfig, MockServer}, }; use std::net::Ipv4Addr; use std::path::PathBuf; -use std::sync::Arc; use types::*; pub fn run<E: EthSpec>(mut env: Environment<E>, matches: &ArgMatches) -> Result<(), String> { - let jwt_path: PathBuf = parse_required(matches, "jwt-output-path")?; + let jwt_output_path: Option<PathBuf> = parse_optional(matches, "jwt-output-path")?; + let jwt_secret_path: Option<PathBuf> = parse_optional(matches, "jwt-secret-path")?; let listen_addr: Ipv4Addr = parse_required(matches, "listen-address")?; let listen_port: u16 = parse_required(matches, "listen-port")?; let all_payloads_valid: bool = parse_required(matches, "all-payloads-valid")?; @@ -25,9 +23,23 @@ pub fn run<E: EthSpec>(mut env: Environment<E>, matches: &ArgMatches) -> Result< let amsterdam_time = parse_optional(matches, "amsterdam-time")?; let handle = env.core_context().executor.handle().unwrap(); - let spec = Arc::new(E::default_spec()); - let jwt_key = JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap(); - std::fs::write(jwt_path, hex::encode(DEFAULT_JWT_SECRET)).unwrap(); + + let jwt_key = if let Some(secret_path) = jwt_secret_path { + let hex_str = std::fs::read_to_string(&secret_path) + .map_err(|e| format!("Failed to read JWT secret file: {}", e))?; + let secret_bytes = hex::decode(strip_prefix(hex_str.trim())) + .map_err(|e| format!("Invalid hex in JWT secret file: {}", e))?; + JwtKey::from_slice(&secret_bytes) + .map_err(|e| format!("Invalid JWT secret length (expected 32 bytes): {}", e))? + } else if let Some(jwt_path) = jwt_output_path { + let jwt_key = JwtKey::from_slice(&DEFAULT_JWT_SECRET) + .map_err(|e| format!("Default JWT secret invalid: {}", e))?; + std::fs::write(jwt_path, hex::encode(jwt_key.as_bytes())) + .map_err(|e| format!("Failed to write JWT secret to output path: {}", e))?; + jwt_key + } else { + return Err("either --jwt-secret-path or --jwt-output-path must be provided".to_string()); + }; let config = MockExecutionConfig { server_config: Config { @@ -35,9 +47,6 @@ pub fn run<E: EthSpec>(mut env: Environment<E>, matches: &ArgMatches) -> Result< listen_port, }, jwt_key, - terminal_difficulty: spec.terminal_total_difficulty, - terminal_block: DEFAULT_TERMINAL_BLOCK, - terminal_block_hash: spec.terminal_block_hash, shanghai_time: Some(shanghai_time), eip7805_time, cancun_time, diff --git a/lcli/src/parse_ssz.rs b/lcli/src/parse_ssz.rs index f1e5c5759a..cd739b2a9e 100644 --- a/lcli/src/parse_ssz.rs +++ b/lcli/src/parse_ssz.rs @@ -141,7 +141,7 @@ fn decode_and_print<T: Serialize>( OutputFormat::Yaml => { println!( "{}", - serde_yaml::to_string(&item) + yaml_serde::to_string(&item) .map_err(|e| format!("Unable to write object to YAML: {e:?}"))? ); } diff --git a/lighthouse/Cargo.toml b/lighthouse/Cargo.toml index ebe00c9be5..3595cf04e7 100644 --- a/lighthouse/Cargo.toml +++ b/lighthouse/Cargo.toml @@ -53,7 +53,6 @@ environment = { workspace = true } eth2_network_config = { workspace = true } ethereum_hashing = { workspace = true } futures = { workspace = true } -lighthouse_tracing = { workspace = true } lighthouse_version = { workspace = true } logging = { workspace = true } metrics = { workspace = true } @@ -63,16 +62,17 @@ opentelemetry-otlp = { workspace = true } opentelemetry_sdk = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } slasher = { workspace = true } store = { workspace = true } task_executor = { workspace = true } tracing = { workspace = true } tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true } +tracing_samplers = { workspace = true } types = { workspace = true } validator_client = { workspace = true } validator_manager = { path = "../validator_manager" } +yaml_serde = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dependencies] malloc_utils = { workspace = true, features = ["jemalloc"] } diff --git a/lighthouse/environment/src/lib.rs b/lighthouse/environment/src/lib.rs index 6694c673ed..1431b03f45 100644 --- a/lighthouse/environment/src/lib.rs +++ b/lighthouse/environment/src/lib.rs @@ -388,7 +388,7 @@ impl<E: EthSpec> Environment<E> { Err(e) => error!(error = ?e, "Could not register SIGHUP handler"), } - future::select(inner_shutdown, future::select_all(handles.into_iter())).await + future::select(inner_shutdown, future::select_all(handles)).await }; match self.runtime().block_on(register_handlers) { diff --git a/lighthouse/environment/tests/testnet_dir/config.yaml b/lighthouse/environment/tests/testnet_dir/config.yaml index 24c4a67225..f155fac826 100644 --- a/lighthouse/environment/tests/testnet_dir/config.yaml +++ b/lighthouse/environment/tests/testnet_dir/config.yaml @@ -47,6 +47,8 @@ TRANSITION_TOTAL_DIFFICULTY: 4294967296 # --------------------------------------------------------------- # 12 seconds SECONDS_PER_SLOT: 12 +# 12 seconds +SLOT_DURATION_MS: 12000 # 14 (estimate from Eth1 mainnet) SECONDS_PER_ETH1_BLOCK: 14 # 2**8 (= 256) epochs ~27 hours @@ -55,6 +57,18 @@ MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 SHARD_COMMITTEE_PERIOD: 256 # 2**11 (= 2,048) Eth1 blocks ~8 hours ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 # Validator cycle @@ -93,6 +107,7 @@ TTFB_TIMEOUT: 5 RESP_TIMEOUT: 10 MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 ATTESTATION_SUBNET_COUNT: 64 ATTESTATION_SUBNET_EXTRA_BITS: 0 ATTESTATION_SUBNET_PREFIX_BITS: 6 diff --git a/lighthouse/src/main.rs b/lighthouse/src/main.rs index c93016a0f5..53946ed273 100644 --- a/lighthouse/src/main.rs +++ b/lighthouse/src/main.rs @@ -29,6 +29,7 @@ use std::process::exit; use std::sync::LazyLock; use task_executor::ShutdownReason; use tracing::{Level, info}; +use tracing_samplers::PrefixBasedSampler; use tracing_subscriber::{Layer, filter::EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use types::{EthSpec, EthSpecId}; use validator_client::ProductionValidatorClient; @@ -675,7 +676,15 @@ fn run<E: EthSpec>( _ => "lighthouse".to_string(), }); + // Use ParentBased sampler with PrefixBasedSampler to only export traces + // that start from known instrumented code paths (lh_ prefix). Child spans + // automatically inherit their parent's sampling decision. + let sampler = opentelemetry_sdk::trace::Sampler::ParentBased(Box::new( + PrefixBasedSampler::new("lh_"), + )); + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_sampler(sampler) .with_batch_exporter(exporter) .with_resource( opentelemetry_sdk::Resource::builder() @@ -730,7 +739,8 @@ fn run<E: EthSpec>( #[cfg(all(feature = "modern", target_arch = "x86_64"))] if !std::is_x86_feature_detected!("adx") { - tracing::warn!( + use tracing::warn; + warn!( advice = "If you get a SIGILL, please try Lighthouse portable build", "CPU seems incompatible with optimized Lighthouse build" ); diff --git a/lighthouse/tests/account_manager.rs b/lighthouse/tests/account_manager.rs index 9bfcae85e5..76839dea39 100644 --- a/lighthouse/tests/account_manager.rs +++ b/lighthouse/tests/account_manager.rs @@ -248,9 +248,9 @@ impl TestValidator { store_withdrawal_key: bool, ) -> Result<Vec<String>, String> { let mut cmd = validator_cmd(); - cmd.arg(format!("--{}", VALIDATOR_DIR_FLAG)) + cmd.arg(CREATE_CMD) + .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(self.validator_dir.clone().into_os_string()) - .arg(CREATE_CMD) .arg(format!("--{}", WALLETS_DIR_FLAG)) .arg(self.wallet.base_dir().into_os_string()) .arg(format!("--{}", WALLET_NAME_FLAG)) @@ -427,9 +427,9 @@ fn validator_import_launchpad() { File::create(src_dir.path().join(NOT_KEYSTORE_NAME)).unwrap(); let mut child = validator_cmd() + .arg(IMPORT_CMD) .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(dst_dir.path().as_os_str()) - .arg(IMPORT_CMD) .arg(format!("--{}", STDIN_INPUTS_FLAG)) // Using tty does not work well with tests. .arg(format!("--{}", import::DIR_FLAG)) .arg(src_dir.path().as_os_str()) @@ -479,10 +479,10 @@ fn validator_import_launchpad() { // Disable all the validators in validator_definition. output_result( validator_cmd() - .arg(format!("--{}", VALIDATOR_DIR_FLAG)) - .arg(dst_dir.path().as_os_str()) .arg(MODIFY_CMD) .arg(DISABLE) + .arg(format!("--{}", VALIDATOR_DIR_FLAG)) + .arg(dst_dir.path().as_os_str()) .arg(format!("--{}", ALL)), ) .unwrap(); @@ -514,10 +514,10 @@ fn validator_import_launchpad() { // Enable keystore validator again output_result( validator_cmd() - .arg(format!("--{}", VALIDATOR_DIR_FLAG)) - .arg(dst_dir.path().as_os_str()) .arg(MODIFY_CMD) .arg(ENABLE) + .arg(format!("--{}", VALIDATOR_DIR_FLAG)) + .arg(dst_dir.path().as_os_str()) .arg(format!("--{}", PUBKEY_FLAG)) .arg(format!("{}", keystore.public_key().unwrap())), ) @@ -560,9 +560,9 @@ fn validator_import_launchpad_no_password_then_add_password() { let validator_import_key_cmd = || { validator_cmd() + .arg(IMPORT_CMD) .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(dst_dir.path().as_os_str()) - .arg(IMPORT_CMD) .arg(format!("--{}", STDIN_INPUTS_FLAG)) // Using tty does not work well with tests. .arg(format!("--{}", import::DIR_FLAG)) .arg(src_dir.path().as_os_str()) @@ -700,9 +700,9 @@ fn validator_import_launchpad_password_file() { .unwrap(); let mut child = validator_cmd() + .arg(IMPORT_CMD) .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(dst_dir.path().as_os_str()) - .arg(IMPORT_CMD) .arg(format!("--{}", import::DIR_FLAG)) .arg(src_dir.path().as_os_str()) .arg(format!("--{}", import::REUSE_PASSWORD_FLAG)) diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index 207324ea33..0c5d9a5933 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -24,7 +24,7 @@ use std::str::FromStr; use std::string::ToString; use std::time::Duration; use tempfile::TempDir; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; use types::{Address, Checkpoint, Epoch, Hash256, MainnetEthSpec}; const DEFAULT_EXECUTION_ENDPOINT: &str = "http://localhost:8551/"; @@ -295,6 +295,21 @@ fn paranoid_block_proposal_on() { .with_config(|config| assert!(config.chain.paranoid_block_proposal)); } +#[test] +fn ignore_ws_check_enabled() { + CommandLineTest::new() + .flag("ignore-ws-check", None) + .run_with_zero_port() + .with_config(|config| assert!(config.chain.ignore_ws_check)); +} + +#[test] +fn ignore_ws_check_default() { + CommandLineTest::new() + .run_with_zero_port() + .with_config(|config| assert!(!config.chain.ignore_ws_check)); +} + #[test] fn reset_payload_statuses_default() { CommandLineTest::new() @@ -386,9 +401,9 @@ fn genesis_backfill_flag() { /// The genesis backfill flag should be enabled if historic states flag is set. #[test] -fn genesis_backfill_with_historic_flag() { +fn genesis_backfill_with_archive_flag() { CommandLineTest::new() - .flag("reconstruct-historic-states", None) + .flag("archive", None) .run_with_zero_port() .with_config(|config| assert!(config.chain.genesis_backfill)); } @@ -2015,17 +2030,24 @@ fn blob_prune_margin_epochs_on_startup_ten() { .with_config(|config| assert!(config.store.blob_prune_margin_epochs == 10)); } #[test] -fn reconstruct_historic_states_flag() { +fn archive_flag() { + CommandLineTest::new() + .flag("archive", None) + .run_with_zero_port() + .with_config(|config| assert!(config.chain.archive)); +} +#[test] +fn archive_flag_alias() { CommandLineTest::new() .flag("reconstruct-historic-states", None) .run_with_zero_port() - .with_config(|config| assert!(config.chain.reconstruct_historic_states)); + .with_config(|config| assert!(config.chain.archive)); } #[test] -fn no_reconstruct_historic_states_flag() { +fn no_archive_flag() { CommandLineTest::new() .run_with_zero_port() - .with_config(|config| assert!(!config.chain.reconstruct_historic_states)); + .with_config(|config| assert!(!config.chain.archive)); } #[test] fn epochs_per_migration_default() { @@ -2332,7 +2354,7 @@ fn enable_proposer_re_orgs_default() { DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, ); assert_eq!( - config.chain.re_org_cutoff(12), + config.chain.re_org_cutoff(Duration::from_secs(12)), Duration::from_secs(12) / DEFAULT_RE_ORG_CUTOFF_DENOMINATOR ); }); @@ -2384,7 +2406,10 @@ fn proposer_re_org_cutoff() { .flag("proposer-reorg-cutoff", Some("500")) .run_with_zero_port() .with_config(|config| { - assert_eq!(config.chain.re_org_cutoff(12), Duration::from_millis(500)) + assert_eq!( + config.chain.re_org_cutoff(Duration::from_secs(12)), + Duration::from_millis(500) + ) }); } @@ -2839,3 +2864,21 @@ fn invalid_block_roots_default_mainnet() { assert!(config.chain.invalid_block_roots.is_empty()); }) } + +#[test] +fn partial_columns() { + CommandLineTest::new() + .flag("enable-partial-columns", None) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_partial_columns); + assert!(config.chain.enable_partial_columns); + }); + // And disabled by default: + CommandLineTest::new() + .run_with_zero_port() + .with_config(|config| { + assert!(!config.network.enable_partial_columns); + assert!(!config.chain.enable_partial_columns); + }) +} diff --git a/lighthouse/tests/exec.rs b/lighthouse/tests/exec.rs index 5379912c13..a25558bc2f 100644 --- a/lighthouse/tests/exec.rs +++ b/lighthouse/tests/exec.rs @@ -65,7 +65,7 @@ pub trait CommandLineTestExec { let spec_file = File::open(tmp_chain_config_path).expect("Unable to open dumped chain spec"); let chain_config: Config = - serde_yaml::from_reader(spec_file).expect("Unable to deserialize config"); + yaml_serde::from_reader(spec_file).expect("Unable to deserialize config"); CompletedTest::new(config, chain_config, tmp_dir) } @@ -102,7 +102,7 @@ pub trait CommandLineTestExec { let spec_file = File::open(tmp_chain_config_path).expect("Unable to open dumped chain spec"); let chain_config: Config = - serde_yaml::from_reader(spec_file).expect("Unable to deserialize config"); + yaml_serde::from_reader(spec_file).expect("Unable to deserialize config"); CompletedTest::new(config, chain_config, tmp_dir) } diff --git a/lighthouse/tests/validator_client.rs b/lighthouse/tests/validator_client.rs index ee3e910b36..6fd5a6538c 100644 --- a/lighthouse/tests/validator_client.rs +++ b/lighthouse/tests/validator_client.rs @@ -758,3 +758,21 @@ fn validator_proposer_nodes() { ); }); } + +// Head monitor is enabled by default. +#[test] +fn head_monitor_default() { + CommandLineTest::new().run().with_config(|config| { + assert!(config.enable_beacon_head_monitor); + }); +} + +#[test] +fn head_monitor_disabled() { + CommandLineTest::new() + .flag("disable-beacon-head-monitor", None) + .run() + .with_config(|config| { + assert!(!config.enable_beacon_head_monitor); + }); +} diff --git a/lighthouse/tests/validator_manager.rs b/lighthouse/tests/validator_manager.rs index d6d720a561..9bad1cdc91 100644 --- a/lighthouse/tests/validator_manager.rs +++ b/lighthouse/tests/validator_manager.rs @@ -16,6 +16,7 @@ use validator_manager::{ list_validators::ListConfig, move_validators::{MoveConfig, PasswordSource, Validators}, }; +use zeroize::Zeroizing; const EXAMPLE_ETH1_ADDRESS: &str = "0x00000000219ab540356cBB839Cbe05303d7705Fa"; @@ -280,6 +281,40 @@ pub fn validator_import_using_both_file_flags() { .assert_failed(); } +#[test] +pub fn validator_import_keystore_file_without_password_flag_should_fail() { + CommandLineTest::validators_import() + .flag("--vc-token", Some("./token.json")) + .flag("--keystore-file", Some("./keystore.json")) + .assert_failed(); +} + +#[test] +pub fn validator_import_keystore_file_with_password_flag_should_pass() { + CommandLineTest::validators_import() + .flag("--vc-token", Some("./token.json")) + .flag("--keystore-file", Some("./keystore.json")) + .flag("--password", Some("abcd")) + .assert_success(|config| { + let expected = ImportConfig { + validators_file_path: None, + keystore_file_path: Some(PathBuf::from("./keystore.json")), + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + ignore_duplicates: false, + password: Some(Zeroizing::new("abcd".into())), + fee_recipient: None, + builder_boost_factor: None, + gas_limit: None, + builder_proposals: None, + enabled: None, + prefer_builder_proposals: None, + }; + assert_eq!(expected, config); + println!("{:?}", expected); + }); +} + #[test] pub fn validator_import_missing_both_file_flags() { CommandLineTest::validators_import() @@ -287,6 +322,36 @@ pub fn validator_import_missing_both_file_flags() { .assert_failed(); } +#[test] +pub fn validator_import_fee_recipient_override() { + CommandLineTest::validators_import() + .flag("--validators-file", Some("./vals.json")) + .flag("--vc-token", Some("./token.json")) + .flag("--suggested-fee-recipient", Some(EXAMPLE_ETH1_ADDRESS)) + .flag("--gas-limit", Some("1337")) + .flag("--builder-proposals", Some("true")) + .flag("--builder-boost-factor", Some("150")) + .flag("--prefer-builder-proposals", Some("true")) + .flag("--enabled", Some("false")) + .assert_success(|config| { + let expected = ImportConfig { + validators_file_path: Some(PathBuf::from("./vals.json")), + keystore_file_path: None, + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + ignore_duplicates: false, + password: None, + fee_recipient: Some(Address::from_str(EXAMPLE_ETH1_ADDRESS).unwrap()), + builder_boost_factor: Some(150), + gas_limit: Some(1337), + builder_proposals: Some(true), + enabled: Some(false), + prefer_builder_proposals: Some(true), + }; + assert_eq!(expected, config); + }); +} + #[test] pub fn validator_move_defaults() { CommandLineTest::validators_move() diff --git a/scripts/local_testnet/network_params.yaml b/scripts/local_testnet/network_params.yaml index a048674e63..083f719c60 100644 --- a/scripts/local_testnet/network_params.yaml +++ b/scripts/local_testnet/network_params.yaml @@ -18,13 +18,16 @@ participants: count: 2 network_params: fulu_fork_epoch: 0 - seconds_per_slot: 6 + slot_duration_ms: 3000 snooper_enabled: false global_log_level: debug +tempo_params: + image: grafana/tempo:2.10.3 additional_services: - dora - spamoor - - prometheus_grafana + - prometheus + - grafana - tempo spamoor_params: image: ethpandaops/spamoor:master diff --git a/scripts/range-sync-coverage.sh b/scripts/range-sync-coverage.sh new file mode 100755 index 0000000000..df438c0c7f --- /dev/null +++ b/scripts/range-sync-coverage.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Aggregate range sync test coverage across all forks +# Usage: ./scripts/range-sync-coverage.sh [--html] +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +TARGET_DIR="${CARGO_TARGET_DIR:-/mnt/ssd/builds/lighthouse-range-sync-tests}" +FORKS=(base altair bellatrix capella deneb electra fulu) +LCOV_DIR="/tmp/range-cov-forks" +MERGED="/tmp/range-cov-merged.lcov" + +rm -rf "$LCOV_DIR" +mkdir -p "$LCOV_DIR" + +echo "=== Running coverage for each fork ===" +for fork in "${FORKS[@]}"; do + echo "--- $fork ---" + CARGO_TARGET_DIR="$TARGET_DIR" FORK_NAME="$fork" \ + cargo llvm-cov --features "network/fake_crypto,network/fork_from_env" \ + -p network --lib --lcov --output-path "$LCOV_DIR/$fork.lcov" \ + -- "sync::tests::range" 2>&1 | grep -E "test result|running" +done + +echo "" +echo "=== Merging lcov files ===" + +# Merge all lcov files: for each source file, take max hit count per line +python3 - "$LCOV_DIR" "$MERGED" << 'PYEOF' +import sys, os, glob +from collections import defaultdict + +lcov_dir = sys.argv[1] +output = sys.argv[2] + +# Parse all lcov files: file -> line -> max hits +coverage = defaultdict(lambda: defaultdict(int)) +fn_coverage = defaultdict(lambda: defaultdict(int)) +current_sf = None + +for lcov_file in sorted(glob.glob(os.path.join(lcov_dir, "*.lcov"))): + with open(lcov_file) as f: + for line in f: + line = line.strip() + if line.startswith("SF:"): + current_sf = line[3:] + elif line.startswith("DA:") and current_sf: + parts = line[3:].split(",") + lineno = int(parts[0]) + hits = int(parts[1]) + coverage[current_sf][lineno] = max(coverage[current_sf][lineno], hits) + elif line.startswith("FNDA:") and current_sf: + parts = line[5:].split(",", 1) + hits = int(parts[0]) + fn_name = parts[1] + fn_coverage[current_sf][fn_name] = max(fn_coverage[current_sf][fn_name], hits) + +# Write merged lcov +with open(output, "w") as f: + for sf in sorted(coverage.keys()): + f.write(f"SF:{sf}\n") + for fn_name, hits in sorted(fn_coverage.get(sf, {}).items()): + f.write(f"FNDA:{hits},{fn_name}\n") + for lineno in sorted(coverage[sf].keys()): + f.write(f"DA:{lineno},{coverage[sf][lineno]}\n") + total = len(coverage[sf]) + covered = sum(1 for h in coverage[sf].values() if h > 0) + f.write(f"LH:{covered}\n") + f.write(f"LF:{total}\n") + f.write("end_of_record\n") + +print(f"Merged {len(glob.glob(os.path.join(lcov_dir, '*.lcov')))} lcov files -> {output}") +PYEOF + +echo "" +echo "=== Range sync coverage (merged across all forks) ===" + +# Extract and display range sync files +python3 - "$MERGED" << 'PYEOF' +import sys +from collections import defaultdict + +current_sf = None +files = {} # short_name -> (total_lines, covered_lines) +lines = defaultdict(dict) + +with open(sys.argv[1]) as f: + for line in f: + line = line.strip() + if line.startswith("SF:"): + current_sf = line[3:] + elif line.startswith("DA:") and current_sf: + parts = line[3:].split(",") + lineno, hits = int(parts[0]), int(parts[1]) + lines[current_sf][lineno] = hits + +# Filter to range sync files +targets = [ + "range_sync/chain.rs", + "range_sync/chain_collection.rs", + "range_sync/range.rs", + "requests/blocks_by_range.rs", + "requests/blobs_by_range.rs", + "requests/data_columns_by_range.rs", +] + +print(f"{'File':<45} {'Lines':>6} {'Covered':>8} {'Missed':>7} {'Coverage':>9}") +print("-" * 80) + +total_all = 0 +covered_all = 0 + +for sf in sorted(lines.keys()): + short = sf.split("sync/")[-1] if "sync/" in sf else sf.split("/")[-1] + if not any(t in sf for t in targets): + continue + total = len(lines[sf]) + covered = sum(1 for h in lines[sf].values() if h > 0) + missed = total - covered + pct = covered / total * 100 if total > 0 else 0 + total_all += total + covered_all += covered + print(f"{short:<45} {total:>6} {covered:>8} {missed:>7} {pct:>8.1f}%") + +print("-" * 80) +pct_all = covered_all / total_all * 100 if total_all > 0 else 0 +print(f"{'TOTAL':<45} {total_all:>6} {covered_all:>8} {total_all - covered_all:>7} {pct_all:>8.1f}%") +PYEOF + +if [ "$1" = "--html" ]; then + echo "" + echo "=== Generating HTML report ===" + genhtml "$MERGED" -o /tmp/range-cov-html --ignore-errors source 2>/dev/null + echo "HTML report: /tmp/range-cov-html/index.html" +fi diff --git a/scripts/tests/doppelganger_protection.sh b/scripts/tests/doppelganger_protection.sh index 9009d49d58..e5ffee494f 100755 --- a/scripts/tests/doppelganger_protection.sh +++ b/scripts/tests/doppelganger_protection.sh @@ -9,7 +9,7 @@ NETWORK_PARAMS_FILE=$SCRIPT_DIR/network_params.yaml BEHAVIOR=$1 ENCLAVE_NAME=local-testnet-$BEHAVIOR -SECONDS_PER_SLOT=$(yq eval ".network_params.seconds_per_slot" $NETWORK_PARAMS_FILE) +SLOT_DURATION_MS=$(yq eval ".network_params.slot_duration_ms" $NETWORK_PARAMS_FILE) KEYS_PER_NODE=$(yq eval ".network_params.num_validator_keys_per_node" $NETWORK_PARAMS_FILE) LH_IMAGE_NAME=$(yq eval ".participants[0].cl_image" $NETWORK_PARAMS_FILE) @@ -56,7 +56,7 @@ GENESIS_DELAY=`curl -s $BN1_HTTP_ADDRESS/eth/v1/config/spec | jq '.data.GENESIS_ CURRENT_TIME=`date +%s` # Note: doppelganger protection can only be started post epoch 0 echo "Waiting until next epoch before starting the next validator client..." -DELAY=$(( $SECONDS_PER_SLOT * 32 + $GENESIS_DELAY + $MIN_GENESIS_TIME - $CURRENT_TIME)) +DELAY=$((($SLOT_DURATION_MS / 1000) * 32 + $GENESIS_DELAY + $MIN_GENESIS_TIME - $CURRENT_TIME)) sleep $DELAY # Use BN2 for the next validator client @@ -98,7 +98,7 @@ EOF # Check if doppelganger VC has stopped and exited. Exit code 1 means the check timed out and VC is still running. check_exit_cmd="until [ \$(get_service_status $service_name) != 'RUNNING' ]; do sleep 1; done" - doppelganger_exit=$(run_command_without_exit "timeout $(( $SECONDS_PER_SLOT * 32 * 2 )) bash -c \"$check_exit_cmd\"") + doppelganger_exit=$(run_command_without_exit "timeout $((($SLOT_DURATION_MS / 1000) * 32 * 2 )) bash -c \"$check_exit_cmd\"") if [[ $doppelganger_exit -eq 1 ]]; then echo "Test failed: expected doppelganger but VC is still running. Check the logs for details." @@ -148,7 +148,7 @@ EOF # # See: https://lighthouse-book.sigmaprime.io/api_validator_inclusion.html echo "Waiting three epochs..." - sleep $(( $SECONDS_PER_SLOT * 32 * 3 )) + sleep $((($SLOT_DURATION_MS / 1000) * 32 * 3 )) # Get VC4 validator keys keys_path=$SCRIPT_DIR/$ENCLAVE_NAME/node_4/validators @@ -176,7 +176,7 @@ EOF # # See: https://lighthouse-book.sigmaprime.io/api_validator_inclusion.html echo "Waiting two more epochs..." - sleep $(( $SECONDS_PER_SLOT * 32 * 2 )) + sleep $(( $SLOT_DURATION_MS / 1000 * 32 * 2 )) for val in 0x*; do [[ -e $val ]] || continue is_attester=$(run_command_without_exit "curl -s $BN1_HTTP_ADDRESS/lighthouse/validator_inclusion/5/$val | jq | grep -q '\"is_previous_epoch_target_attester\": true'") diff --git a/scripts/tests/genesis-sync-config-electra.yaml b/scripts/tests/genesis-sync-config-electra.yaml index 1d1ed4d315..0e41a5d165 100644 --- a/scripts/tests/genesis-sync-config-electra.yaml +++ b/scripts/tests/genesis-sync-config-electra.yaml @@ -11,7 +11,7 @@ participants: cl_image: lighthouse:local validator_count: 0 network_params: - seconds_per_slot: 6 + slot_duration_ms: 6000 electra_fork_epoch: 0 fulu_fork_epoch: 100000 # a really big number so this test stays in electra preset: "minimal" diff --git a/scripts/tests/genesis-sync-config-fulu.yaml b/scripts/tests/genesis-sync-config-fulu.yaml index 6d2c2647a9..5ca76a7736 100644 --- a/scripts/tests/genesis-sync-config-fulu.yaml +++ b/scripts/tests/genesis-sync-config-fulu.yaml @@ -20,7 +20,7 @@ participants: supernode: false validator_count: 0 network_params: - seconds_per_slot: 6 + slot_duration_ms: 6000 fulu_fork_epoch: 0 preset: "minimal" additional_services: diff --git a/scripts/tests/network_params.yaml b/scripts/tests/network_params.yaml index 35916ac1e4..30654deaae 100644 --- a/scripts/tests/network_params.yaml +++ b/scripts/tests/network_params.yaml @@ -10,7 +10,7 @@ participants: count: 4 network_params: fulu_fork_epoch: 0 - seconds_per_slot: 3 + slot_duration_ms: 3000 num_validator_keys_per_node: 20 global_log_level: debug snooper_enabled: false diff --git a/slasher/service/src/lib.rs b/slasher/service/src/lib.rs index ac15b49ee9..69ec59aa2c 100644 --- a/slasher/service/src/lib.rs +++ b/slasher/service/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] mod service; pub use service::SlasherService; diff --git a/slasher/src/attestation_queue.rs b/slasher/src/attestation_queue.rs index 62a1bb0945..e99a3708ad 100644 --- a/slasher/src/attestation_queue.rs +++ b/slasher/src/attestation_queue.rs @@ -2,8 +2,17 @@ use crate::{AttesterRecord, Config, IndexedAttesterRecord}; use parking_lot::Mutex; use std::collections::BTreeMap; use std::sync::{Arc, Weak}; +use tracing::warn; use types::{EthSpec, Hash256, IndexedAttestation}; +/// Hard cap on validator indices accepted by the slasher. +/// +/// Any attestation referencing a validator index above this limit is silently dropped during +/// grouping. This is a defence-in-depth measure to prevent pathological memory allocation if an +/// attestation with a bogus index somehow reaches the slasher. The value (2^23 = 8,388,608) +/// provides generous headroom above the current mainnet validator set (~2M). +const MAX_VALIDATOR_INDEX: u64 = 8_388_608; + /// Staging area for attestations received from the network. /// /// Attestations are not grouped by validator index at this stage so that they can be easily @@ -72,6 +81,14 @@ impl<E: EthSpec> AttestationBatch<E> { let mut grouped_attestations = GroupedAttestations { subqueues: vec![] }; for ((validator_index, _), indexed_record) in self.attesters { + if validator_index >= MAX_VALIDATOR_INDEX { + warn!( + validator_index, + "Dropping slasher attestation with out-of-range validator index" + ); + break; + } + let subqueue_id = config.validator_chunk_index(validator_index); if subqueue_id >= grouped_attestations.subqueues.len() { diff --git a/slasher/src/config.rs b/slasher/src/config.rs index 144016efd2..50b4b7a858 100644 --- a/slasher/src/config.rs +++ b/slasher/src/config.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::num::NonZeroUsize; use std::path::PathBuf; use strum::{Display, EnumString, VariantNames}; -use types::non_zero_usize::new_non_zero_usize; +use types::new_non_zero_usize; use types::{Epoch, EthSpec, IndexedAttestation}; pub const DEFAULT_CHUNK_SIZE: usize = 16; diff --git a/slasher/src/slasher.rs b/slasher/src/slasher.rs index 5d26c5a6da..8d34a34f3e 100644 --- a/slasher/src/slasher.rs +++ b/slasher/src/slasher.rs @@ -74,6 +74,11 @@ impl<E: EthSpec> Slasher<E> { &self.config } + /// Return the number of attestations in the queue. + pub fn attestation_queue_len(&self) -> usize { + self.attestation_queue.len() + } + /// Accept an attestation from the network and queue it for processing. pub fn accept_attestation(&self, attestation: IndexedAttestation<E>) { self.attestation_queue.queue(attestation); diff --git a/slasher/src/test_utils.rs b/slasher/src/test_utils.rs index 20d1ee9217..82b7f885ec 100644 --- a/slasher/src/test_utils.rs +++ b/slasher/src/test_utils.rs @@ -6,7 +6,7 @@ use types::{ AttestationData, AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, BeaconBlockHeader, ChainSpec, Checkpoint, Epoch, EthSpec, Hash256, IndexedAttestation, MainnetEthSpec, SignedBeaconBlockHeader, Slot, - indexed_attestation::{IndexedAttestationBase, IndexedAttestationElectra}, + attestation::{IndexedAttestationBase, IndexedAttestationElectra}, }; pub type E = MainnetEthSpec; diff --git a/testing/ef_tests/Cargo.toml b/testing/ef_tests/Cargo.toml index cef201ee91..9d09c3dfe6 100644 --- a/testing/ef_tests/Cargo.toml +++ b/testing/ef_tests/Cargo.toml @@ -32,7 +32,6 @@ rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } -serde_yaml = { workspace = true } snap = { workspace = true } ssz_types = { workspace = true } state_processing = { workspace = true } @@ -41,3 +40,4 @@ tree_hash = { workspace = true } tree_hash_derive = { workspace = true } typenum = { workspace = true } types = { workspace = true } +yaml_serde = { workspace = true } diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index 0ead9d0047..63d1907b96 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.6.0-beta.1 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.6 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 1f70881a88..53fb626e7e 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -47,8 +47,19 @@ excluded_paths = [ "bls12-381-tests/hash_to_G2", "tests/.*/eip7732", "tests/.*/eip7805", + # Heze fork is not implemented + "tests/.*/heze/.*", # Ignore MatrixEntry SSZ tests for now. - "tests/.*/fulu/ssz_static/MatrixEntry/.*", + "tests/.*/.*/ssz_static/MatrixEntry/.*", + # TODO: partial data column not implemented yet + "tests/.*/.*/ssz_static/PartialDataColumn.*/.*", + # TODO(gloas): Ignore Gloas light client stuff for now + "tests/.*/gloas/ssz_static/LightClient.*/.*", + "tests/.*/gloas/light_client", + # Execution payload header is irrelevant after Gloas, this type will probably be deleted. + "tests/.*/gloas/ssz_static/ExecutionPayloadHeader/.*", + # ForkChoiceNode is internal to fork choice and probably doesn't need SSZ tests. + "tests/.*/gloas/ssz_static/ForkChoiceNode/.*", # EIP-7916 is still in draft and hasn't been implemented yet https://eips.ethereum.org/EIPS/eip-7916 "tests/general/phase0/ssz_generic/progressive_bitlist", "tests/general/phase0/ssz_generic/basic_progressive_list", @@ -59,11 +70,15 @@ excluded_paths = [ # Ignore full epoch tests for now (just test the sub-transitions). "tests/.*/.*/epoch_processing/.*/pre_epoch.ssz_snappy", "tests/.*/.*/epoch_processing/.*/post_epoch.ssz_snappy", - # Ignore gloas tests for now - "tests/.*/gloas/.*", # Ignore KZG tests that target internal kzg library functions "tests/.*/compute_verify_cell_kzg_proof_batch_challenge/.*", "tests/.*/compute_challenge/.*", + # We don't need these manifest files at the moment. + "tests/.*/manifest.yaml", + # TODO: gossip condition tests not implemented yet + "tests/.*/.*/networking/.*", + # TODO: fast confirmation rule not merged yet + "tests/.*/.*/fast_confirmation", ] diff --git a/testing/ef_tests/download_test_vectors.sh b/testing/ef_tests/download_test_vectors.sh index 21f74e817f..cb45aeb922 100755 --- a/testing/ef_tests/download_test_vectors.sh +++ b/testing/ef_tests/download_test_vectors.sh @@ -4,13 +4,13 @@ set -Eeuo pipefail TESTS=("general" "minimal" "mainnet") version=${1} -if [[ "$version" == "nightly" ]]; then +if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then if [[ -z "${GITHUB_TOKEN:-}" ]]; then echo "Error GITHUB_TOKEN is not set" exit 1 fi - for cmd in unzip jq; do + for cmd in jq; do if ! command -v "${cmd}" >/dev/null 2>&1; then echo "Error ${cmd} is not installed" exit 1 @@ -21,9 +21,13 @@ if [[ "$version" == "nightly" ]]; then api="https://api.github.com" auth_header="Authorization: token ${GITHUB_TOKEN}" - run_id=$(curl -s -H "${auth_header}" \ - "${api}/repos/${repo}/actions/workflows/generate_vectors.yml/runs?branch=dev&status=success&per_page=1" | - jq -r '.workflow_runs[0].id') + if [[ "$version" == "nightly" ]]; then + run_id=$(curl --fail -s -H "${auth_header}" \ + "${api}/repos/${repo}/actions/workflows/tests.yml/runs?branch=master&status=success&per_page=1" | + jq -r '.workflow_runs[0].id') + else + run_id="${version#nightly-}" + fi if [[ "${run_id}" == "null" || -z "${run_id}" ]]; then echo "No successful nightly workflow run found" @@ -31,7 +35,7 @@ if [[ "$version" == "nightly" ]]; then fi echo "Downloading nightly test vectors for run: ${run_id}" - curl -s -H "${auth_header}" "${api}/repos/${repo}/actions/runs/${run_id}/artifacts" | + curl --fail -H "${auth_header}" "${api}/repos/${repo}/actions/runs/${run_id}/artifacts" | jq -c '.artifacts[] | {name, url: .archive_download_url}' | while read -r artifact; do name=$(echo "${artifact}" | jq -r .name) @@ -44,13 +48,10 @@ if [[ "$version" == "nightly" ]]; then echo "Downloading artifact: ${name}" curl --progress-bar --location --show-error --retry 3 --retry-all-errors --fail \ -H "${auth_header}" -H "Accept: application/vnd.github+json" \ - --output "${name}.zip" "${url}" || { + --output "${name}" "${url}" || { echo "Failed to download ${name}" exit 1 } - - unzip -qo "${name}.zip" - rm -f "${name}.zip" done else for test in "${TESTS[@]}"; do diff --git a/testing/ef_tests/src/cases/compute_columns_for_custody_groups.rs b/testing/ef_tests/src/cases/compute_columns_for_custody_groups.rs index 16d3eaf7af..e30f4c3ae1 100644 --- a/testing/ef_tests/src/cases/compute_columns_for_custody_groups.rs +++ b/testing/ef_tests/src/cases/compute_columns_for_custody_groups.rs @@ -1,7 +1,7 @@ use super::*; use serde::Deserialize; use std::marker::PhantomData; -use types::data_column_custody_group::{CustodyIndex, compute_columns_for_custody_group}; +use types::data::{CustodyIndex, compute_columns_for_custody_group}; #[derive(Debug, Clone, Deserialize)] #[serde(bound = "E: EthSpec", deny_unknown_fields)] diff --git a/testing/ef_tests/src/cases/epoch_processing.rs b/testing/ef_tests/src/cases/epoch_processing.rs index f143643ec3..ec243f05cc 100644 --- a/testing/ef_tests/src/cases/epoch_processing.rs +++ b/testing/ef_tests/src/cases/epoch_processing.rs @@ -12,7 +12,7 @@ use state_processing::per_epoch_processing::effective_balance_updates::{ process_effective_balance_updates, process_effective_balance_updates_slow, }; use state_processing::per_epoch_processing::single_pass::{ - SinglePassConfig, process_epoch_single_pass, process_proposer_lookahead, + SinglePassConfig, process_epoch_single_pass, process_proposer_lookahead, process_ptc_window, }; use state_processing::per_epoch_processing::{ altair, base, @@ -58,6 +58,8 @@ pub struct Eth1DataReset; #[derive(Debug)] pub struct PendingBalanceDeposits; #[derive(Debug)] +pub struct PendingDepositsChurn; +#[derive(Debug)] pub struct PendingConsolidations; #[derive(Debug)] pub struct EffectiveBalanceUpdates; @@ -79,6 +81,10 @@ pub struct InactivityUpdates; pub struct ParticipationFlagUpdates; #[derive(Debug)] pub struct ProposerLookahead; +#[derive(Debug)] +pub struct PtcWindow; +#[derive(Debug)] +pub struct BuilderPendingPayments; type_name!( JustificationAndFinalization, @@ -89,6 +95,7 @@ type_name!(RegistryUpdates, "registry_updates"); type_name!(Slashings, "slashings"); type_name!(Eth1DataReset, "eth1_data_reset"); type_name!(PendingBalanceDeposits, "pending_deposits"); +type_name!(PendingDepositsChurn, "pending_deposits_churn"); type_name!(PendingConsolidations, "pending_consolidations"); type_name!(EffectiveBalanceUpdates, "effective_balance_updates"); type_name!(SlashingsReset, "slashings_reset"); @@ -100,6 +107,8 @@ type_name!(SyncCommitteeUpdates, "sync_committee_updates"); type_name!(InactivityUpdates, "inactivity_updates"); type_name!(ParticipationFlagUpdates, "participation_flag_updates"); type_name!(ProposerLookahead, "proposer_lookahead"); +type_name!(PtcWindow, "ptc_window"); +type_name!(BuilderPendingPayments, "builder_pending_payments"); impl<E: EthSpec> EpochTransition<E> for JustificationAndFinalization { fn run(state: &mut BeaconState<E>, spec: &ChainSpec) -> Result<(), EpochProcessingError> { @@ -185,6 +194,20 @@ impl<E: EthSpec> EpochTransition<E> for PendingBalanceDeposits { } } +impl<E: EthSpec> EpochTransition<E> for PendingDepositsChurn { + fn run(state: &mut BeaconState<E>, spec: &ChainSpec) -> Result<(), EpochProcessingError> { + process_epoch_single_pass( + state, + spec, + SinglePassConfig { + pending_deposits: true, + ..SinglePassConfig::disable_all() + }, + ) + .map(|_| ()) + } +} + impl<E: EthSpec> EpochTransition<E> for PendingConsolidations { fn run(state: &mut BeaconState<E>, spec: &ChainSpec) -> Result<(), EpochProcessingError> { initialize_epoch_cache(state, spec)?; @@ -293,6 +316,30 @@ impl<E: EthSpec> EpochTransition<E> for ProposerLookahead { } } +impl<E: EthSpec> EpochTransition<E> for PtcWindow { + fn run(state: &mut BeaconState<E>, spec: &ChainSpec) -> Result<(), EpochProcessingError> { + if state.fork_name_unchecked().gloas_enabled() { + process_ptc_window(state, spec).map(|_| ()) + } else { + Ok(()) + } + } +} + +impl<E: EthSpec> EpochTransition<E> for BuilderPendingPayments { + fn run(state: &mut BeaconState<E>, spec: &ChainSpec) -> Result<(), EpochProcessingError> { + process_epoch_single_pass( + state, + spec, + SinglePassConfig { + builder_pending_payments: true, + ..SinglePassConfig::disable_all() + }, + ) + .map(|_| ()) + } +} + impl<E: EthSpec, T: EpochTransition<E>> LoadCase for EpochProcessing<E, T> { fn load_from_dir(path: &Path, fork_name: ForkName) -> Result<Self, Error> { let spec = &testing_spec::<E>(fork_name); @@ -356,6 +403,14 @@ impl<E: EthSpec, T: EpochTransition<E>> Case for EpochProcessing<E, T> { return false; } + if !fork_name.gloas_enabled() + && (T::name() == "builder_pending_payments" + || T::name() == "ptc_window" + || T::name() == "pending_deposits_churn") + { + return false; + } + true } diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 8e9d438a24..8b0b74d256 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -3,7 +3,7 @@ use crate::decode::{ssz_decode_file, ssz_decode_file_with, ssz_decode_state, yam use ::fork_choice::{PayloadVerificationStatus, ProposerHeadError}; use beacon_chain::beacon_proposer_cache::compute_proposer_duties_from_head; use beacon_chain::blob_verification::GossipBlobError; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::chain_config::{ DEFAULT_RE_ORG_HEAD_THRESHOLD, DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, DEFAULT_RE_ORG_PARENT_THRESHOLD, DisallowedReOrgOffsets, @@ -19,18 +19,23 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{BeaconChainHarness, EphemeralHarnessType}, }; -use execution_layer::{PayloadStatusV1, json_structures::JsonPayloadStatusV1Status}; +use execution_layer::{ + PayloadStatusV1, PayloadStatusV1Status, json_structures::JsonPayloadStatusV1Status, +}; use serde::Deserialize; use ssz_derive::Decode; +use state_processing::VerifySignatures; +use state_processing::envelope_processing::verify_execution_payload_envelope; use state_processing::state_advance::complete_state_advance; use std::future::Future; use std::sync::Arc; use std::time::Duration; use types::{ Attestation, AttestationRef, AttesterSlashing, AttesterSlashingRef, BeaconBlock, BeaconState, - BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecarList, - DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, KzgProof, - ProposerPreparationData, SignedBeaconBlock, Slot, Uint256, + BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecar, + DataColumnSidecarList, DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, + KzgProof, ProposerPreparationData, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + Uint256, }; // When set to true, cache any states fetched from the db. @@ -72,6 +77,7 @@ pub struct Checks { proposer_boost_root: Option<Hash256>, get_proposer_head: Option<Hash256>, should_override_forkchoice_update: Option<ShouldOverrideFcu>, + head_payload_status: Option<u8>, } #[derive(Debug, Clone, Deserialize)] @@ -94,7 +100,15 @@ impl From<PayloadStatus> for PayloadStatusV1 { #[derive(Debug, Clone, Deserialize)] #[serde(untagged, deny_unknown_fields)] -pub enum Step<TBlock, TBlobs, TColumns, TAttestation, TAttesterSlashing, TPowBlock> { +pub enum Step< + TBlock, + TBlobs, + TColumns, + TAttestation, + TAttesterSlashing, + TPowBlock, + TExecutionPayload = String, +> { Tick { tick: u64, }, @@ -128,6 +142,10 @@ pub enum Step<TBlock, TBlobs, TColumns, TAttestation, TAttesterSlashing, TPowBlo columns: Option<TColumns>, valid: bool, }, + OnExecutionPayload { + execution_payload: TExecutionPayload, + valid: bool, + }, } #[derive(Debug, Clone, Deserialize)] @@ -151,6 +169,7 @@ pub struct ForkChoiceTest<E: EthSpec> { Attestation<E>, AttesterSlashing<E>, PowBlock, + SignedExecutionPayloadEnvelope<E>, >, >, } @@ -252,7 +271,15 @@ impl<E: EthSpec> LoadCase for ForkChoiceTest<E> { columns_vec .into_iter() .map(|column| { - ssz_decode_file(&path.join(format!("{column}.ssz_snappy"))) + ssz_decode_file_with( + &path.join(format!("{column}.ssz_snappy")), + |bytes| { + DataColumnSidecar::from_ssz_bytes_for_fork( + bytes, fork_name, + ) + .map(Arc::new) + }, + ) }) .collect::<Result<Vec<_>, _>>() }) @@ -263,6 +290,17 @@ impl<E: EthSpec> LoadCase for ForkChoiceTest<E> { valid, }) } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + let envelope = + ssz_decode_file(&path.join(format!("{execution_payload}.ssz_snappy")))?; + Ok(Step::OnExecutionPayload { + execution_payload: envelope, + valid, + }) + } }) .collect::<Result<_, _>>()?; let anchor_state = ssz_decode_state(&path.join("anchor_state.ssz_snappy"), spec)?; @@ -342,6 +380,7 @@ impl<E: EthSpec> Case for ForkChoiceTest<E> { proposer_boost_root, get_proposer_head, should_override_forkchoice_update: should_override_fcu, + head_payload_status, } = checks.as_ref(); if let Some(expected_head) = head { @@ -388,6 +427,10 @@ impl<E: EthSpec> Case for ForkChoiceTest<E> { if let Some(expected_proposer_head) = get_proposer_head { tester.check_expected_proposer_head(*expected_proposer_head)?; } + + if let Some(expected_status) = head_payload_status { + tester.check_head_payload_status(*expected_status)?; + } } Step::MaybeValidBlockAndColumns { @@ -397,6 +440,12 @@ impl<E: EthSpec> Case for ForkChoiceTest<E> { } => { tester.process_block_and_columns(block.clone(), columns.clone(), *valid)?; } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + tester.process_execution_payload(execution_payload, *valid)?; + } } } @@ -431,7 +480,7 @@ impl<E: EthSpec> Tester<E> { .spec(spec.clone()) .keypairs(vec![]) .chain_config(ChainConfig { - reconstruct_historic_states: true, + archive: true, ..ChainConfig::default() }) .genesis_state_ephemeral_store(case.anchor_state.clone()) @@ -471,7 +520,7 @@ impl<E: EthSpec> Tester<E> { let since_genesis = tick .checked_sub(genesis_time) .ok_or_else(|| Error::FailedToParseTest("tick is prior to genesis".into()))?; - let slots_since_genesis = since_genesis / self.spec.seconds_per_slot; + let slots_since_genesis = since_genesis / self.spec.get_slot_duration().as_secs(); Ok(self.spec.genesis_slot + slots_since_genesis) } @@ -515,13 +564,15 @@ impl<E: EthSpec> Tester<E> { valid: bool, ) -> Result<(), Error> { let block_root = block.canonical_root(); + let mut data_column_success = true; if let Some(columns) = columns.clone() { let gossip_verified_data_columns = columns .into_iter() .map(|column| { - let subnet_id = DataColumnSubnetId::from_column_index(column.index, &self.spec); + let subnet_id = + DataColumnSubnetId::from_column_index(*column.index(), &self.spec); GossipVerifiedDataColumn::new(column.clone(), subnet_id, &self.harness.chain) .unwrap_or_else(|_| { data_column_success = false; @@ -544,7 +595,7 @@ impl<E: EthSpec> Tester<E> { let result: Result<Result<Hash256, ()>, _> = self .block_on_dangerous(self.harness.chain.process_block( block_root, - RpcBlock::new_without_blobs(Some(block_root), block.clone()), + LookupBlock::new(block.clone()), NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -565,6 +616,18 @@ impl<E: EthSpec> Tester<E> { self.apply_invalid_block(&block)?; } + // Per spec test runner: an on_block step implies receiving block's attestations + // and attester slashings. + if success { + for attestation in block.message().body().attestations() { + let att = attestation.clone_as_attestation(); + let _ = self.process_attestation(&att); + } + for attester_slashing in block.message().body().attester_slashings() { + self.process_attester_slashing(attester_slashing); + } + } + Ok(()) } @@ -592,11 +655,8 @@ impl<E: EthSpec> Tester<E> { // Zipping will stop when any of the zipped lists runs out, which is what we want. Some // of the tests don't provide enough proofs/blobs, and should fail the availability // check. - for (i, ((blob, kzg_proof), kzg_commitment)) in blobs - .into_iter() - .zip(proofs) - .zip(commitments.into_iter()) - .enumerate() + for (i, ((blob, kzg_proof), kzg_commitment)) in + blobs.into_iter().zip(proofs).zip(commitments).enumerate() { let blob_sidecar = Arc::new(BlobSidecar { index: i as u64, @@ -634,7 +694,7 @@ impl<E: EthSpec> Tester<E> { let result: Result<Result<Hash256, ()>, _> = self .block_on_dangerous(self.harness.chain.process_block( block_root, - RpcBlock::new_without_blobs(Some(block_root), block.clone()), + LookupBlock::new(block.clone()), NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -655,6 +715,18 @@ impl<E: EthSpec> Tester<E> { self.apply_invalid_block(&block)?; } + // Per spec test runner: an on_block step implies receiving block's attestations + // and attester slashings. + if success { + for attestation in block.message().body().attestations() { + let att = attestation.clone_as_attestation(); + let _ = self.process_attestation(&att); + } + for attester_slashing in block.message().body().attester_slashings() { + self.process_attester_slashing(attester_slashing); + } + } + Ok(()) } @@ -710,6 +782,7 @@ impl<E: EthSpec> Tester<E> { block_delay, &state, PayloadVerificationStatus::Irrelevant, + block.message().proposer_index(), &self.harness.chain.spec, ); @@ -894,7 +967,7 @@ impl<E: EthSpec> Tester<E> { ) -> Result<(), Error> { let mut fc = self.harness.chain.canonical_head.fork_choice_write_lock(); let slot = self.harness.chain.slot().unwrap(); - let canonical_head = fc.get_head(slot, &self.harness.spec).unwrap(); + let (canonical_head, _) = fc.get_head(slot, &self.harness.spec).unwrap(); let proposer_head_result = fc.get_proposer_head( slot, canonical_head, @@ -904,7 +977,7 @@ impl<E: EthSpec> Tester<E> { DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, ); let proposer_head = match proposer_head_result { - Ok(head) => head.parent_node.root, + Ok(head) => head.parent_node.root(), Err(ProposerHeadError::DoNotReOrg(_)) => canonical_head, _ => panic!("Unexpected error in get proposer head"), }; @@ -912,6 +985,115 @@ impl<E: EthSpec> Tester<E> { check_equal("proposer_head", proposer_head, expected_proposer_head) } + pub fn process_execution_payload( + &self, + signed_envelope: &SignedExecutionPayloadEnvelope<E>, + valid: bool, + ) -> Result<(), Error> { + let block_root = signed_envelope.message.beacon_block_root; + let block_hash = signed_envelope.message.payload.block_hash; + let store = &self.harness.chain.store; + let spec = &self.harness.chain.spec; + + // Simulate the EL: pre-configure the mock execution engine to return VALID + // for envelopes the test expects to be valid. Invalid envelopes are left + // unconfigured so the mock EE's default (SYNCING) rejects them. + let el = self.harness.mock_execution_layer.as_ref().unwrap(); + if valid { + el.server.set_new_payload_status( + block_hash, + PayloadStatusV1 { + status: JsonPayloadStatusV1Status::Valid.into(), + latest_valid_hash: Some(block_hash), + validation_error: None, + }, + ); + } + + // Attempt to verify the envelope against the block's post-state. + let verification_result = (|| { + let block = store + .get_blinded_block(&block_root) + .map_err(|e| Error::InternalError(format!("Failed to load block: {e:?}")))? + .ok_or_else(|| { + Error::InternalError(format!("Block not found for root {block_root:?}")) + })?; + let block_state_root = block.state_root(); + + let state = store + .get_hot_state(&block_state_root, CACHE_STATE_IN_TESTS) + .map_err(|e| Error::InternalError(format!("Failed to load state: {e:?}")))? + .ok_or_else(|| { + Error::InternalError(format!("State not found for root {block_state_root:?}")) + })?; + + verify_execution_payload_envelope( + &state, + signed_envelope, + VerifySignatures::True, + block_state_root, + spec, + ) + .map_err(|e| { + Error::InternalError(format!("Failed to process execution payload: {e:?}")) + })?; + + // Check the mock EE's response for this block hash (simulates newPayload). + let ee_valid = el + .server + .ctx + .get_new_payload_status(&block_hash) + .and_then(|r| r.ok()) + .is_some_and(|s| s.status == PayloadStatusV1Status::Valid); + if !ee_valid { + return Err(Error::InternalError(format!( + "Mock EE rejected payload with block hash {block_hash:?}", + ))); + } + + Ok(()) + })(); + + if valid { + verification_result?; + + // Store the envelope so that child blocks can load the parent's payload. + store + .put_payload_envelope(&block_root, signed_envelope) + .map_err(|e| { + Error::InternalError(format!( + "Failed to store payload envelope for {block_root:?}: {e:?}", + )) + })?; + + self.harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .map_err(|e| { + Error::InternalError(format!( + "on_execution_payload for block root {} failed: {:?}", + block_root, e + )) + })?; + } else if verification_result.is_ok() { + return Err(Error::DidntFail(format!( + "on_execution_payload envelope for block root {} should have failed", + block_root + ))); + } + + Ok(()) + } + + pub fn check_head_payload_status(&self, expected_status: u8) -> Result<(), Error> { + let head = self.find_head()?; + // PayloadStatus repr: Empty=0, Full=1, Pending=2 (matches spec constants). + let actual = head.head_payload_status() as u8; + check_equal("head_payload_status", actual, expected_status) + } + pub fn check_should_override_fcu( &self, expected_should_override_fcu: ShouldOverrideFcu, diff --git a/testing/ef_tests/src/cases/get_custody_groups.rs b/testing/ef_tests/src/cases/get_custody_groups.rs index 1c1294305f..4221fa06e0 100644 --- a/testing/ef_tests/src/cases/get_custody_groups.rs +++ b/testing/ef_tests/src/cases/get_custody_groups.rs @@ -2,7 +2,7 @@ use super::*; use alloy_primitives::U256; use serde::Deserialize; use std::marker::PhantomData; -use types::data_column_custody_group::get_custody_groups; +use types::data::get_custody_groups; #[derive(Debug, Clone, Deserialize)] #[serde(bound = "E: EthSpec", deny_unknown_fields)] diff --git a/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs b/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs index 7973af861f..200f439c28 100644 --- a/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs +++ b/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs @@ -1,6 +1,6 @@ use super::*; use crate::case_result::compare_result; -use kzg::{Bytes48, Error as KzgError}; +use kzg::Error as KzgError; use serde::Deserialize; use std::marker::PhantomData; @@ -47,8 +47,8 @@ impl<E: EthSpec> Case for KZGVerifyCellKZGProofBatch<E> { let result = parse_input(&self.input).and_then(|(cells, proofs, cell_indices, commitments)| { - let proofs: Vec<Bytes48> = proofs.iter().map(|&proof| proof.into()).collect(); - let commitments: Vec<Bytes48> = commitments.iter().map(|&c| c.into()).collect(); + let proofs = proofs.iter().map(|&proof| proof.0).collect::<Vec<_>>(); + let commitments = commitments.iter().map(|&c| c.0).collect::<Vec<_>>(); let cells = cells.iter().map(|c| c.as_ref()).collect::<Vec<_>>(); let kzg = get_kzg(); match kzg.verify_cell_proof_batch(&cells, &proofs, cell_indices, &commitments) { diff --git a/testing/ef_tests/src/cases/merkle_proof_validity.rs b/testing/ef_tests/src/cases/merkle_proof_validity.rs index 9ea6327a86..55098a2f82 100644 --- a/testing/ef_tests/src/cases/merkle_proof_validity.rs +++ b/testing/ef_tests/src/cases/merkle_proof_validity.rs @@ -7,7 +7,7 @@ use typenum::Unsigned; use types::{ BeaconBlockBody, BeaconBlockBodyCapella, BeaconBlockBodyDeneb, BeaconBlockBodyEip7805, BeaconBlockBodyElectra, BeaconBlockBodyFulu, BeaconBlockBodyGloas, BeaconState, FullPayload, - light_client_update, + light_client, }; #[derive(Debug, Clone, Deserialize)] @@ -98,16 +98,16 @@ impl<E: EthSpec> Case for BeaconStateMerkleProofValidity<E> { state.update_tree_hash_cache().unwrap(); let proof = match self.merkle_proof.leaf_index { - light_client_update::CURRENT_SYNC_COMMITTEE_INDEX_ELECTRA - | light_client_update::CURRENT_SYNC_COMMITTEE_INDEX => { + light_client::consts::CURRENT_SYNC_COMMITTEE_INDEX_ELECTRA + | light_client::consts::CURRENT_SYNC_COMMITTEE_INDEX => { state.compute_current_sync_committee_proof() } - light_client_update::NEXT_SYNC_COMMITTEE_INDEX_ELECTRA - | light_client_update::NEXT_SYNC_COMMITTEE_INDEX => { + light_client::consts::NEXT_SYNC_COMMITTEE_INDEX_ELECTRA + | light_client::consts::NEXT_SYNC_COMMITTEE_INDEX => { state.compute_next_sync_committee_proof() } - light_client_update::FINALIZED_ROOT_INDEX_ELECTRA - | light_client_update::FINALIZED_ROOT_INDEX => state.compute_finalized_root_proof(), + light_client::consts::FINALIZED_ROOT_INDEX_ELECTRA + | light_client::consts::FINALIZED_ROOT_INDEX => state.compute_finalized_root_proof(), _ => { return Err(Error::FailedToParseTest( "Could not retrieve merkle proof, invalid index".to_string(), diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index a53bce927c..f5c999920d 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -5,21 +5,25 @@ use crate::decode::{ssz_decode_file, ssz_decode_file_with, ssz_decode_state, yam use serde::Deserialize; use ssz::Decode; use state_processing::common::update_progressive_balances_cache::initialize_progressive_balances_cache; +use state_processing::envelope_processing::verify_execution_payload_envelope; use state_processing::epoch_cache::initialize_epoch_cache; use state_processing::per_block_processing::process_operations::{ - process_consolidation_requests, process_deposit_requests, process_withdrawal_requests, + process_consolidation_requests, process_deposit_requests_post_gloas, + process_deposit_requests_pre_gloas, process_withdrawal_requests, }; use state_processing::{ ConsensusContext, + envelope_processing::EnvelopeProcessingError, per_block_processing::{ VerifyBlockRoot, VerifySignatures, errors::BlockProcessingError, - process_block_header, process_execution_payload, + process_block_header, process_execution_payload, process_execution_payload_bid, process_operations::{ - altair_deneb, base, process_attester_slashings, process_bls_to_execution_changes, - process_deposits, process_exits, process_proposer_slashings, + altair_deneb, base, gloas, process_attester_slashings, + process_bls_to_execution_changes, process_deposits, process_exits, + process_payload_attestation, process_proposer_slashings, }, - process_sync_aggregate, process_withdrawals, + process_parent_execution_payload, process_sync_aggregate, withdrawals, }, }; use std::fmt::Debug; @@ -27,8 +31,9 @@ use types::{ Attestation, AttesterSlashing, BeaconBlock, BeaconBlockBody, BeaconBlockBodyBellatrix, BeaconBlockBodyCapella, BeaconBlockBodyDeneb, BeaconBlockBodyElectra, BeaconBlockBodyFulu, BeaconState, BlindedPayload, ConsolidationRequest, Deposit, DepositRequest, ExecutionPayload, - ForkVersionDecode, FullPayload, ProposerSlashing, SignedBlsToExecutionChange, - SignedVoluntaryExit, SyncAggregate, WithdrawalRequest, + ForkVersionDecode, FullPayload, PayloadAttestation, ProposerSlashing, + SignedBlsToExecutionChange, SignedExecutionPayloadEnvelope, SignedVoluntaryExit, SyncAggregate, + WithdrawalRequest, }; #[derive(Debug, Clone, Default, Deserialize)] @@ -45,7 +50,28 @@ struct ExecutionMetadata { /// Newtype for testing withdrawals. #[derive(Debug, Clone, Deserialize)] pub struct WithdrawalsPayload<E: EthSpec> { - payload: FullPayload<E>, + payload: Option<ExecutionPayload<E>>, +} + +/// Newtype for testing voluntary exit churn (Gloas+). +/// +/// The test case applies the same `process_voluntary_exit` operation as the regular +/// `voluntary_exit` test, but under the `voluntary_exit_churn` handler directory. +#[derive(Debug, Clone)] +pub struct VoluntaryExitChurn { + exit: SignedVoluntaryExit, +} + +/// Newtype for testing execution payload bids. +#[derive(Debug, Clone, Deserialize)] +pub struct ExecutionPayloadBidBlock<E: EthSpec> { + block: BeaconBlock<E>, +} + +/// Newtype for testing parent execution payload processing. +#[derive(Debug, Clone, Deserialize)] +pub struct ParentExecutionPayloadBlock<E: EthSpec> { + block: BeaconBlock<E>, } #[derive(Debug, Clone)] @@ -58,6 +84,8 @@ pub struct Operations<E: EthSpec, O: Operation<E>> { } pub trait Operation<E: EthSpec>: Debug + Sync + Sized { + type Error: Debug; + fn handler_name() -> String; fn filename() -> String { @@ -75,10 +103,12 @@ pub trait Operation<E: EthSpec>: Debug + Sync + Sized { state: &mut BeaconState<E>, spec: &ChainSpec, _: &Operations<E, Self>, - ) -> Result<(), BlockProcessingError>; + ) -> Result<(), Self::Error>; } impl<E: EthSpec> Operation<E> for Attestation<E> { + type Error = BlockProcessingError; + fn handler_name() -> String { "attestation".into() } @@ -98,9 +128,18 @@ impl<E: EthSpec> Operation<E> for Attestation<E> { _: &Operations<E, Self>, ) -> Result<(), BlockProcessingError> { initialize_epoch_cache(state, spec)?; + initialize_progressive_balances_cache(state, spec)?; let mut ctxt = ConsensusContext::new(state.slot()); - if state.fork_name_unchecked().altair_enabled() { - initialize_progressive_balances_cache(state, spec)?; + if state.fork_name_unchecked().gloas_enabled() { + gloas::process_attestation( + state, + self.to_ref(), + 0, + &mut ctxt, + VerifySignatures::True, + spec, + ) + } else if state.fork_name_unchecked().altair_enabled() { altair_deneb::process_attestation( state, self.to_ref(), @@ -122,6 +161,8 @@ impl<E: EthSpec> Operation<E> for Attestation<E> { } impl<E: EthSpec> Operation<E> for AttesterSlashing<E> { + type Error = BlockProcessingError; + fn handler_name() -> String { "attester_slashing".into() } @@ -153,6 +194,8 @@ impl<E: EthSpec> Operation<E> for AttesterSlashing<E> { } impl<E: EthSpec> Operation<E> for Deposit { + type Error = BlockProcessingError; + fn handler_name() -> String { "deposit".into() } @@ -177,6 +220,8 @@ impl<E: EthSpec> Operation<E> for Deposit { } impl<E: EthSpec> Operation<E> for ProposerSlashing { + type Error = BlockProcessingError; + fn handler_name() -> String { "proposer_slashing".into() } @@ -204,6 +249,8 @@ impl<E: EthSpec> Operation<E> for ProposerSlashing { } impl<E: EthSpec> Operation<E> for SignedVoluntaryExit { + type Error = BlockProcessingError; + fn handler_name() -> String { "voluntary_exit".into() } @@ -227,7 +274,43 @@ impl<E: EthSpec> Operation<E> for SignedVoluntaryExit { } } +impl<E: EthSpec> Operation<E> for VoluntaryExitChurn { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "voluntary_exit_churn".into() + } + + fn filename() -> String { + "voluntary_exit.ssz_snappy".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, _spec: &ChainSpec) -> Result<Self, Error> { + ssz_decode_file(path).map(|exit| VoluntaryExitChurn { exit }) + } + + fn apply_to( + &self, + state: &mut BeaconState<E>, + spec: &ChainSpec, + _: &Operations<E, Self>, + ) -> Result<(), BlockProcessingError> { + process_exits( + state, + std::slice::from_ref(&self.exit), + VerifySignatures::True, + spec, + ) + } +} + impl<E: EthSpec> Operation<E> for BeaconBlock<E> { + type Error = BlockProcessingError; + fn handler_name() -> String { "block_header".into() } @@ -259,6 +342,8 @@ impl<E: EthSpec> Operation<E> for BeaconBlock<E> { } impl<E: EthSpec> Operation<E> for SyncAggregate<E> { + type Error = BlockProcessingError; + fn handler_name() -> String { "sync_aggregate".into() } @@ -287,6 +372,8 @@ impl<E: EthSpec> Operation<E> for SyncAggregate<E> { } impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, FullPayload<E>> { + type Error = BlockProcessingError; + fn handler_name() -> String { "execution_payload".into() } @@ -296,7 +383,7 @@ impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, FullPayload<E>> { } fn is_enabled_for_fork(fork_name: ForkName) -> bool { - fork_name.bellatrix_enabled() + fork_name.bellatrix_enabled() && !fork_name.gloas_enabled() } fn decode(path: &Path, fork_name: ForkName, _spec: &ChainSpec) -> Result<Self, Error> { @@ -307,8 +394,7 @@ impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, FullPayload<E>> { ForkName::Deneb => BeaconBlockBody::Deneb(<_>::from_ssz_bytes(bytes)?), ForkName::Electra => BeaconBlockBody::Electra(<_>::from_ssz_bytes(bytes)?), ForkName::Fulu => BeaconBlockBody::Fulu(<_>::from_ssz_bytes(bytes)?), - // TODO(EIP-7732): See if we need to handle Gloas here - _ => panic!(), + _ => panic!("Not supported after Gloas"), }) }) } @@ -330,7 +416,10 @@ impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, FullPayload<E>> { } } } + impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, BlindedPayload<E>> { + type Error = BlockProcessingError; + fn handler_name() -> String { "execution_payload".into() } @@ -340,7 +429,7 @@ impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, BlindedPayload<E>> { } fn is_enabled_for_fork(fork_name: ForkName) -> bool { - fork_name.bellatrix_enabled() + fork_name.bellatrix_enabled() && !fork_name.gloas_enabled() } fn decode(path: &Path, fork_name: ForkName, _spec: &ChainSpec) -> Result<Self, Error> { @@ -367,8 +456,7 @@ impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, BlindedPayload<E>> { let inner = <BeaconBlockBodyFulu<E, FullPayload<E>>>::from_ssz_bytes(bytes)?; BeaconBlockBody::Fulu(inner.clone_as_blinded()) } - // TODO(EIP-7732): See if we need to handle Gloas here - _ => panic!(), + _ => panic!("Not supported after Gloas"), }) }) } @@ -391,7 +479,116 @@ impl<E: EthSpec> Operation<E> for BeaconBlockBody<E, BlindedPayload<E>> { } } +impl<E: EthSpec> Operation<E> for SignedExecutionPayloadEnvelope<E> { + type Error = EnvelopeProcessingError; + + fn handler_name() -> String { + "execution_payload".into() + } + + fn filename() -> String { + "signed_envelope.ssz_snappy".into() + } + + fn is_enabled_for_fork(_fork_name: ForkName) -> bool { + // TODO(gloas): re-enable this test when enabled upstream + // fork_name.gloas_enabled() + false + } + + fn decode(path: &Path, _: ForkName, _spec: &ChainSpec) -> Result<Self, Error> { + ssz_decode_file(path) + } + + fn apply_to( + &self, + state: &mut BeaconState<E>, + spec: &ChainSpec, + extra: &Operations<E, Self>, + ) -> Result<(), Self::Error> { + let valid = extra + .execution_metadata + .as_ref() + .is_some_and(|e| e.execution_valid); + if valid { + let block_state_root = state.update_tree_hash_cache()?; + verify_execution_payload_envelope( + state, + self, + VerifySignatures::True, + block_state_root, + spec, + ) + } else { + Err(EnvelopeProcessingError::ExecutionInvalid) + } + } +} + +impl<E: EthSpec> Operation<E> for ExecutionPayloadBidBlock<E> { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "execution_payload_bid".into() + } + + fn filename() -> String { + "block.ssz_snappy".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, spec: &ChainSpec) -> Result<Self, Error> { + ssz_decode_file_with(path, |bytes| BeaconBlock::from_ssz_bytes(bytes, spec)) + .map(|block| ExecutionPayloadBidBlock { block }) + } + + fn apply_to( + &self, + state: &mut BeaconState<E>, + spec: &ChainSpec, + _: &Operations<E, Self>, + ) -> Result<(), BlockProcessingError> { + process_execution_payload_bid(state, self.block.to_ref(), VerifySignatures::True, spec)?; + Ok(()) + } +} + +impl<E: EthSpec> Operation<E> for ParentExecutionPayloadBlock<E> { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "parent_execution_payload".into() + } + + fn filename() -> String { + "block.ssz_snappy".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, spec: &ChainSpec) -> Result<Self, Error> { + ssz_decode_file_with(path, |bytes| BeaconBlock::from_ssz_bytes(bytes, spec)) + .map(|block| ParentExecutionPayloadBlock { block }) + } + + fn apply_to( + &self, + state: &mut BeaconState<E>, + spec: &ChainSpec, + _: &Operations<E, Self>, + ) -> Result<(), BlockProcessingError> { + process_parent_execution_payload(state, self.block.to_ref(), spec) + } +} + impl<E: EthSpec> Operation<E> for WithdrawalsPayload<E> { + type Error = BlockProcessingError; + fn handler_name() -> String { "withdrawals".into() } @@ -405,12 +602,17 @@ impl<E: EthSpec> Operation<E> for WithdrawalsPayload<E> { } fn decode(path: &Path, fork_name: ForkName, _spec: &ChainSpec) -> Result<Self, Error> { - ssz_decode_file_with(path, |bytes| { - ExecutionPayload::from_ssz_bytes_by_fork(bytes, fork_name) - }) - .map(|payload| WithdrawalsPayload { - payload: payload.into(), - }) + if fork_name.gloas_enabled() { + // No payload present or required for Gloas tests. + Ok(WithdrawalsPayload { payload: None }) + } else { + ssz_decode_file_with(path, |bytes| { + ExecutionPayload::from_ssz_bytes_by_fork(bytes, fork_name) + }) + .map(|payload| WithdrawalsPayload { + payload: Some(payload), + }) + } } fn apply_to( @@ -419,12 +621,22 @@ impl<E: EthSpec> Operation<E> for WithdrawalsPayload<E> { spec: &ChainSpec, _: &Operations<E, Self>, ) -> Result<(), BlockProcessingError> { - // TODO(EIP-7732): implement separate gloas and non-gloas variants of process_withdrawals - process_withdrawals::<_, FullPayload<_>>(state, self.payload.to_ref(), spec) + if state.fork_name_unchecked().gloas_enabled() { + withdrawals::gloas::process_withdrawals(state, spec) + } else { + let full_payload = FullPayload::from(self.payload.clone().unwrap()); + withdrawals::capella_electra::process_withdrawals::<_, FullPayload<_>>( + state, + full_payload.to_ref(), + spec, + ) + } } } impl<E: EthSpec> Operation<E> for SignedBlsToExecutionChange { + type Error = BlockProcessingError; + fn handler_name() -> String { "bls_to_execution_change".into() } @@ -457,6 +669,8 @@ impl<E: EthSpec> Operation<E> for SignedBlsToExecutionChange { } impl<E: EthSpec> Operation<E> for WithdrawalRequest { + type Error = BlockProcessingError; + fn handler_name() -> String { "withdrawal_request".into() } @@ -481,6 +695,8 @@ impl<E: EthSpec> Operation<E> for WithdrawalRequest { } impl<E: EthSpec> Operation<E> for DepositRequest { + type Error = BlockProcessingError; + fn handler_name() -> String { "deposit_request".into() } @@ -499,11 +715,17 @@ impl<E: EthSpec> Operation<E> for DepositRequest { spec: &ChainSpec, _extra: &Operations<E, Self>, ) -> Result<(), BlockProcessingError> { - process_deposit_requests(state, std::slice::from_ref(self), spec) + if state.fork_name_unchecked().gloas_enabled() { + process_deposit_requests_post_gloas(state, std::slice::from_ref(self), spec) + } else { + process_deposit_requests_pre_gloas(state, std::slice::from_ref(self), spec) + } } } impl<E: EthSpec> Operation<E> for ConsolidationRequest { + type Error = BlockProcessingError; + fn handler_name() -> String { "consolidation_request".into() } @@ -527,6 +749,32 @@ impl<E: EthSpec> Operation<E> for ConsolidationRequest { } } +impl<E: EthSpec> Operation<E> for PayloadAttestation<E> { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "payload_attestation".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, _spec: &ChainSpec) -> Result<Self, Error> { + ssz_decode_file(path) + } + + fn apply_to( + &self, + state: &mut BeaconState<E>, + spec: &ChainSpec, + _extra: &Operations<E, Self>, + ) -> Result<(), BlockProcessingError> { + let mut ctxt = ConsensusContext::new(state.slot()); + process_payload_attestation(state, self, 0, VerifySignatures::True, &mut ctxt, spec) + } +} + impl<E: EthSpec, O: Operation<E>> LoadCase for Operations<E, O> { fn load_from_dir(path: &Path, fork_name: ForkName) -> Result<Self, Error> { let spec = &testing_spec::<E>(fork_name); @@ -549,8 +797,9 @@ impl<E: EthSpec, O: Operation<E>> LoadCase for Operations<E, O> { // Check BLS setting here before SSZ deserialization, as most types require signatures // to be valid. + let operation_path = path.join(O::filename()); let (operation, bls_error) = if metadata.bls_setting.unwrap_or_default().check().is_ok() { - match O::decode(&path.join(O::filename()), fork_name, spec) { + match O::decode(&operation_path, fork_name, spec) { Ok(op) => (Some(op), None), Err(Error::InvalidBLSInput(error)) => (None, Some(error)), Err(e) => return Err(e), diff --git a/testing/ef_tests/src/decode.rs b/testing/ef_tests/src/decode.rs index 2074ffce23..f4aa17fb08 100644 --- a/testing/ef_tests/src/decode.rs +++ b/testing/ef_tests/src/decode.rs @@ -33,14 +33,14 @@ pub fn log_file_access<P: AsRef<Path>>(file_accessed: P) { } pub fn yaml_decode<T: serde::de::DeserializeOwned>(string: &str) -> Result<T, Error> { - serde_yaml::from_str(string).map_err(|e| Error::FailedToParseTest(format!("{:?}", e))) + yaml_serde::from_str(string).map_err(|e| Error::FailedToParseTest(format!("{:?}", e))) } pub fn context_yaml_decode<'de, T, C>(string: &'de str, context: C) -> Result<T, Error> where T: ContextDeserialize<'de, C>, { - let deserializer = serde_yaml::Deserializer::from_str(string); + let deserializer = yaml_serde::Deserializer::from_str(string); T::context_deserialize(deserializer, context) .map_err(|e| Error::FailedToParseTest(format!("{:?}", e))) } diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index a5b2ffada3..e380f51c0a 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -22,7 +22,7 @@ pub trait Handler { // Add forks here to exclude them from EF spec testing. Helpful for adding future or // unspecified forks. fn disabled_forks(&self) -> Vec<ForkName> { - vec![ForkName::Gloas] + vec![] } fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { @@ -305,6 +305,10 @@ impl<T, E> SszStaticHandler<T, E> { Self::for_forks(vec![ForkName::Fulu]) } + pub fn gloas_only() -> Self { + Self::for_forks(vec![ForkName::Gloas]) + } + pub fn altair_and_later() -> Self { Self::for_forks(ForkName::list_all()[1..].to_vec()) } @@ -329,9 +333,17 @@ impl<T, E> SszStaticHandler<T, E> { Self::for_forks(ForkName::list_all()[6..].to_vec()) } + pub fn gloas_and_later() -> Self { + Self::for_forks(ForkName::list_all()[7..].to_vec()) + } + pub fn pre_electra() -> Self { Self::for_forks(ForkName::list_all()[0..5].to_vec()) } + + pub fn pre_capella() -> Self { + Self::for_forks(ForkName::list_all()[0..3].to_vec()) + } } /// Handler for SSZ types that implement `CachedTreeHash`. @@ -579,6 +591,15 @@ impl<E: EthSpec + TypeName> Handler for RewardsHandler<E> { fn handler_name(&self) -> String { self.handler_name.to_string() } + + fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { + if self.handler_name == "inactivity_scores" { + // These tests were added in v1.7.0-alpha.2 and are available for Altair and later. + fork_name.altair_enabled() + } else { + true + } + } } #[derive(Educe)] @@ -687,15 +708,31 @@ impl<E: EthSpec + TypeName> Handler for ForkChoiceHandler<E> { return false; } - // No FCU override tests prior to bellatrix. + // No FCU override tests prior to bellatrix, and removed in Gloas. if self.handler_name == "should_override_forkchoice_update" - && !fork_name.bellatrix_enabled() + && (!fork_name.bellatrix_enabled() || fork_name.gloas_enabled()) { return false; } - // Deposit tests exist only after Electra. - if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { + // Deposit tests exist only for Electra and Fulu (not Gloas). + if self.handler_name == "deposit_with_reorg" + && (!fork_name.electra_enabled() || fork_name.gloas_enabled()) + { + return false; + } + + // Proposer head tests removed in Gloas. + if self.handler_name == "get_proposer_head" && fork_name.gloas_enabled() { + return false; + } + + // on_execution_payload_envelope and get_parent_payload_status tests exist only for + // Gloas and later. + if (self.handler_name == "on_execution_payload_envelope" + || self.handler_name == "get_parent_payload_status") + && !fork_name.gloas_enabled() + { return false; } @@ -703,6 +740,10 @@ impl<E: EthSpec + TypeName> Handler for ForkChoiceHandler<E> { // run them with fake crypto. cfg!(not(feature = "fake_crypto")) } + + fn disabled_forks(&self) -> Vec<ForkName> { + vec![] + } } #[derive(Educe)] @@ -732,6 +773,11 @@ impl<E: EthSpec + TypeName> Handler for OptimisticSyncHandler<E> { fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { fork_name.bellatrix_enabled() && cfg!(not(feature = "fake_crypto")) } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas optimistic sync tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -952,6 +998,11 @@ impl<E: EthSpec> Handler for KZGComputeCellsHandler<E> { fn handler_name(&self) -> String { "compute_cells".into() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -972,6 +1023,11 @@ impl<E: EthSpec> Handler for KZGComputeCellsAndKZGProofHandler<E> { fn handler_name(&self) -> String { "compute_cells_and_kzg_proofs".into() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -992,6 +1048,11 @@ impl<E: EthSpec> Handler for KZGVerifyCellKZGProofBatchHandler<E> { fn handler_name(&self) -> String { "verify_cell_kzg_proof_batch".into() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1012,6 +1073,11 @@ impl<E: EthSpec> Handler for KZGRecoverCellsAndKZGProofHandler<E> { fn handler_name(&self) -> String { "recover_cells_and_kzg_proofs".into() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1036,6 +1102,11 @@ impl<E: EthSpec + TypeName> Handler for KzgInclusionMerkleProofValidityHandler<E fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { fork_name.deneb_enabled() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas KZG merkle proof tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1060,6 +1131,11 @@ impl<E: EthSpec + TypeName> Handler for MerkleProofValidityHandler<E> { fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { fork_name.altair_enabled() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas light client tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1085,6 +1161,11 @@ impl<E: EthSpec + TypeName> Handler for LightClientUpdateHandler<E> { // Enabled in Altair fork_name.altair_enabled() } + + fn disabled_forks(&self) -> Vec<ForkName> { + // TODO(gloas): remove once we have Gloas light client tests + vec![ForkName::Gloas] + } } #[derive(Educe)] diff --git a/testing/ef_tests/src/lib.rs b/testing/ef_tests/src/lib.rs index 8ec4860cab..bead5825ed 100644 --- a/testing/ef_tests/src/lib.rs +++ b/testing/ef_tests/src/lib.rs @@ -1,11 +1,12 @@ pub use case_result::CaseResult; -pub use cases::WithdrawalsPayload; pub use cases::{ - Case, EffectiveBalanceUpdates, Eth1DataReset, FeatureName, HistoricalRootsUpdate, - HistoricalSummariesUpdate, InactivityUpdates, JustificationAndFinalization, - ParticipationFlagUpdates, ParticipationRecordUpdates, PendingBalanceDeposits, - PendingConsolidations, ProposerLookahead, RandaoMixesReset, RegistryUpdates, - RewardsAndPenalties, Slashings, SlashingsReset, SyncCommitteeUpdates, + BuilderPendingPayments, Case, EffectiveBalanceUpdates, Eth1DataReset, ExecutionPayloadBidBlock, + FeatureName, HistoricalRootsUpdate, HistoricalSummariesUpdate, InactivityUpdates, + JustificationAndFinalization, ParentExecutionPayloadBlock, ParticipationFlagUpdates, + ParticipationRecordUpdates, PendingBalanceDeposits, PendingConsolidations, + PendingDepositsChurn, ProposerLookahead, PtcWindow, RandaoMixesReset, RegistryUpdates, + RewardsAndPenalties, Slashings, SlashingsReset, SyncCommitteeUpdates, VoluntaryExitChurn, + WithdrawalsPayload, }; pub use decode::log_file_access; pub use error::Error; diff --git a/testing/ef_tests/src/type_name.rs b/testing/ef_tests/src/type_name.rs index f57170d219..f64fd38ef7 100644 --- a/testing/ef_tests/src/type_name.rs +++ b/testing/ef_tests/src/type_name.rs @@ -1,5 +1,5 @@ //! Mapping from types to canonical string identifiers used in testing. -use types::historical_summary::HistoricalSummary; +use types::state::HistoricalSummary; use types::*; pub trait TypeName { @@ -55,12 +55,15 @@ type_name_generic!(BeaconBlockBodyCapella, "BeaconBlockBody"); type_name_generic!(BeaconBlockBodyDeneb, "BeaconBlockBody"); type_name_generic!(BeaconBlockBodyElectra, "BeaconBlockBody"); type_name_generic!(BeaconBlockBodyFulu, "BeaconBlockBody"); +type_name_generic!(BeaconBlockBodyGloas, "BeaconBlockBody"); type_name!(BeaconBlockHeader); type_name_generic!(BeaconState); type_name!(BlobIdentifier); type_name_generic!(DataColumnsByRootIdentifier, "DataColumnsByRootIdentifier"); type_name_generic!(BlobSidecar); type_name_generic!(DataColumnSidecar); +type_name_generic!(DataColumnSidecarFulu, "DataColumnSidecar"); +type_name_generic!(DataColumnSidecarGloas, "DataColumnSidecar"); type_name!(Checkpoint); type_name!(ConsolidationRequest); type_name_generic!(ContributionAndProof); @@ -69,6 +72,9 @@ type_name!(DepositData); type_name!(DepositMessage); type_name!(DepositRequest); type_name!(Eth1Data); +type_name!(Builder); +type_name!(BuilderPendingPayment); +type_name!(BuilderPendingWithdrawal); type_name!(WithdrawalRequest); type_name_generic!(ExecutionPayload); type_name_generic!(ExecutionPayloadBellatrix, "ExecutionPayload"); @@ -77,6 +83,7 @@ type_name_generic!(ExecutionPayloadDeneb, "ExecutionPayload"); type_name_generic!(ExecutionPayloadElectra, "ExecutionPayload"); type_name_generic!(ExecutionPayloadFulu, "ExecutionPayload"); type_name_generic!(ExecutionPayloadEip7805, "ExecutionPayload"); +type_name_generic!(ExecutionPayloadGloas, "ExecutionPayload"); type_name_generic!(FullPayload, "ExecutionPayload"); type_name_generic!(ExecutionPayloadHeader); type_name_generic!(ExecutionPayloadHeaderBellatrix, "ExecutionPayloadHeader"); @@ -85,7 +92,11 @@ type_name_generic!(ExecutionPayloadHeaderDeneb, "ExecutionPayloadHeader"); type_name_generic!(ExecutionPayloadHeaderElectra, "ExecutionPayloadHeader"); type_name_generic!(ExecutionPayloadHeaderFulu, "ExecutionPayloadHeader"); type_name_generic!(ExecutionPayloadHeaderEip7805, "ExecutionPayloadHeader"); +type_name_generic!(ExecutionPayloadBid); +type_name_generic!(SignedExecutionPayloadBid); type_name_generic!(ExecutionRequests); +type_name_generic!(ExecutionPayloadEnvelope); +type_name_generic!(SignedExecutionPayloadEnvelope); type_name_generic!(BlindedPayload, "ExecutionPayloadHeader"); type_name!(Fork); type_name!(ForkData); @@ -93,6 +104,7 @@ type_name_generic!(HistoricalBatch); type_name_generic!(IndexedAttestation); type_name_generic!(IndexedAttestationBase, "IndexedAttestation"); type_name_generic!(IndexedAttestationElectra, "IndexedAttestation"); +type_name_generic!(IndexedPayloadAttestation); type_name_generic!(LightClientBootstrap); type_name_generic!(LightClientBootstrapAltair, "LightClientBootstrap"); type_name_generic!(LightClientBootstrapCapella, "LightClientBootstrap"); @@ -145,10 +157,15 @@ type_name_generic!(LightClientUpdateDeneb, "LightClientUpdate"); type_name_generic!(LightClientUpdateElectra, "LightClientUpdate"); type_name_generic!(LightClientUpdateFulu, "LightClientUpdate"); type_name_generic!(PendingAttestation); +type_name_generic!(PayloadAttestation); +type_name!(PayloadAttestationData); +type_name!(PayloadAttestationMessage); type_name!(PendingConsolidation); type_name!(PendingPartialWithdrawal); type_name!(PendingDeposit); type_name!(ProposerSlashing); +type_name!(ProposerPreferences); +type_name!(SignedProposerPreferences); type_name_generic!(SignedAggregateAndProof); type_name_generic!(SignedAggregateAndProofBase, "SignedAggregateAndProof"); type_name_generic!(SignedAggregateAndProofElectra, "SignedAggregateAndProof"); diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 0cec69c97e..ca383efdb0 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -87,6 +87,30 @@ fn operations_execution_payload_blinded() { OperationsHandler::<MainnetEthSpec, BeaconBlockBody<_, BlindedPayload<_>>>::default().run(); } +#[test] +fn operations_execution_payload_envelope() { + OperationsHandler::<MinimalEthSpec, SignedExecutionPayloadEnvelope<_>>::default().run(); + OperationsHandler::<MainnetEthSpec, SignedExecutionPayloadEnvelope<_>>::default().run(); +} + +#[test] +fn operations_execution_payload_bid() { + OperationsHandler::<MinimalEthSpec, ExecutionPayloadBidBlock<_>>::default().run(); + OperationsHandler::<MainnetEthSpec, ExecutionPayloadBidBlock<_>>::default().run(); +} + +#[test] +fn operations_parent_execution_payload() { + OperationsHandler::<MinimalEthSpec, ParentExecutionPayloadBlock<_>>::default().run(); + OperationsHandler::<MainnetEthSpec, ParentExecutionPayloadBlock<_>>::default().run(); +} + +#[test] +fn operations_payload_attestation() { + OperationsHandler::<MinimalEthSpec, PayloadAttestation<_>>::default().run(); + OperationsHandler::<MainnetEthSpec, PayloadAttestation<_>>::default().run(); +} + #[test] fn operations_withdrawals() { OperationsHandler::<MinimalEthSpec, WithdrawalsPayload<_>>::default().run(); @@ -94,7 +118,7 @@ fn operations_withdrawals() { } #[test] -fn operations_withdrawal_reqeusts() { +fn operations_withdrawal_requests() { OperationsHandler::<MinimalEthSpec, WithdrawalRequest>::default().run(); OperationsHandler::<MainnetEthSpec, WithdrawalRequest>::default().run(); } @@ -118,6 +142,12 @@ fn operations_bls_to_execution_change() { OperationsHandler::<MainnetEthSpec, SignedBlsToExecutionChange>::default().run(); } +#[test] +fn operations_voluntary_exit_churn() { + OperationsHandler::<MinimalEthSpec, VoluntaryExitChurn>::default().run(); + OperationsHandler::<MainnetEthSpec, VoluntaryExitChurn>::default().run(); +} + #[test] fn sanity_blocks() { SanityBlocksHandler::<MinimalEthSpec>::default().run(); @@ -239,10 +269,14 @@ macro_rules! ssz_static_test_no_run { #[cfg(feature = "fake_crypto")] mod ssz_static { use ef_tests::{Handler, SszStaticHandler, SszStaticTHCHandler, SszStaticWithSpecHandler}; - use types::historical_summary::HistoricalSummary; + use types::state::HistoricalSummary; use types::{ - AttesterSlashingBase, AttesterSlashingElectra, ConsolidationRequest, DepositRequest, - LightClientBootstrapAltair, PendingDeposit, PendingPartialWithdrawal, WithdrawalRequest, *, + AttesterSlashingBase, AttesterSlashingElectra, Builder, BuilderPendingPayment, + BuilderPendingWithdrawal, ConsolidationRequest, DepositRequest, ExecutionPayloadBid, + ExecutionPayloadEnvelope, IndexedPayloadAttestation, LightClientBootstrapAltair, + PayloadAttestation, PayloadAttestationData, PayloadAttestationMessage, PendingDeposit, + PendingPartialWithdrawal, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + WithdrawalRequest, *, }; ssz_static_test!(attestation_data, AttestationData); @@ -257,8 +291,19 @@ mod ssz_static { ssz_static_test!(eth1_data, Eth1Data); ssz_static_test!(fork, Fork); ssz_static_test!(fork_data, ForkData); - ssz_static_test!(historical_batch, HistoricalBatch<_>); - ssz_static_test!(pending_attestation, PendingAttestation<_>); + // `HistoricalBatch` was removed in Capella, so test vectors only exist for Base, + // Altair and Bellatrix. + #[test] + fn historical_batch() { + SszStaticHandler::<HistoricalBatch<MinimalEthSpec>, MinimalEthSpec>::pre_capella().run(); + SszStaticHandler::<HistoricalBatch<MainnetEthSpec>, MainnetEthSpec>::pre_capella().run(); + } + // `PendingAttestation` was removed in Altair, so test vectors only exist for Base. + #[test] + fn pending_attestation() { + SszStaticHandler::<PendingAttestation<MinimalEthSpec>, MinimalEthSpec>::base_only().run(); + SszStaticHandler::<PendingAttestation<MainnetEthSpec>, MainnetEthSpec>::base_only().run(); + } ssz_static_test!(proposer_slashing, ProposerSlashing); ssz_static_test!( signed_beacon_block, @@ -368,6 +413,10 @@ mod ssz_static { .run(); SszStaticHandler::<BeaconBlockBodyFulu<MinimalEthSpec>, MinimalEthSpec>::fulu_only().run(); SszStaticHandler::<BeaconBlockBodyFulu<MainnetEthSpec>, MainnetEthSpec>::fulu_only().run(); + SszStaticHandler::<BeaconBlockBodyGloas<MinimalEthSpec>, MinimalEthSpec>::gloas_only() + .run(); + SszStaticHandler::<BeaconBlockBodyGloas<MainnetEthSpec>, MainnetEthSpec>::gloas_only() + .run(); } // Altair and later @@ -595,6 +644,10 @@ mod ssz_static { .run(); SszStaticHandler::<ExecutionPayloadFulu<MinimalEthSpec>, MinimalEthSpec>::fulu_only().run(); SszStaticHandler::<ExecutionPayloadFulu<MainnetEthSpec>, MainnetEthSpec>::fulu_only().run(); + SszStaticHandler::<ExecutionPayloadGloas<MinimalEthSpec>, MinimalEthSpec>::gloas_only() + .run(); + SszStaticHandler::<ExecutionPayloadGloas<MainnetEthSpec>, MainnetEthSpec>::gloas_only() + .run(); } #[test] @@ -621,6 +674,20 @@ mod ssz_static { .run(); } + #[test] + fn execution_payload_bid() { + SszStaticHandler::<ExecutionPayloadBid<MinimalEthSpec>, MinimalEthSpec>::gloas_and_later() + .run(); + SszStaticHandler::<ExecutionPayloadBid<MainnetEthSpec>, MainnetEthSpec>::gloas_and_later() + .run(); + } + + #[test] + fn signed_execution_payload_bid() { + SszStaticHandler::<SignedExecutionPayloadBid<MinimalEthSpec>, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<SignedExecutionPayloadBid<MainnetEthSpec>, MainnetEthSpec>::gloas_and_later().run(); + } + #[test] fn withdrawal() { SszStaticHandler::<Withdrawal, MinimalEthSpec>::capella_and_later().run(); @@ -659,9 +726,13 @@ mod ssz_static { #[test] fn data_column_sidecar() { - SszStaticHandler::<DataColumnSidecar<MinimalEthSpec>, MinimalEthSpec>::fulu_and_later() + SszStaticHandler::<DataColumnSidecarFulu<MinimalEthSpec>, MinimalEthSpec>::fulu_only() .run(); - SszStaticHandler::<DataColumnSidecar<MainnetEthSpec>, MainnetEthSpec>::fulu_and_later() + SszStaticHandler::<DataColumnSidecarFulu<MainnetEthSpec>, MainnetEthSpec>::fulu_only() + .run(); + SszStaticHandler::<DataColumnSidecarGloas<MinimalEthSpec>, MinimalEthSpec>::gloas_only() + .run(); + SszStaticHandler::<DataColumnSidecarGloas<MainnetEthSpec>, MainnetEthSpec>::gloas_only() .run(); } @@ -722,6 +793,81 @@ mod ssz_static { SszStaticHandler::<ExecutionRequests<MinimalEthSpec>, MinimalEthSpec>::electra_and_later() .run(); } + + // Gloas and later + #[test] + fn builder() { + SszStaticHandler::<Builder, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<Builder, MainnetEthSpec>::gloas_and_later().run(); + } + + #[test] + fn builder_pending_payment() { + SszStaticHandler::<BuilderPendingPayment, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<BuilderPendingPayment, MainnetEthSpec>::gloas_and_later().run(); + } + + #[test] + fn builder_pending_withdrawal() { + SszStaticHandler::<BuilderPendingWithdrawal, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<BuilderPendingWithdrawal, MainnetEthSpec>::gloas_and_later().run(); + } + + #[test] + fn payload_attestation_data() { + SszStaticHandler::<PayloadAttestationData, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<PayloadAttestationData, MainnetEthSpec>::gloas_and_later().run(); + } + + #[test] + fn payload_attestation() { + SszStaticHandler::<PayloadAttestation<MinimalEthSpec>, MinimalEthSpec>::gloas_and_later() + .run(); + SszStaticHandler::<PayloadAttestation<MainnetEthSpec>, MainnetEthSpec>::gloas_and_later() + .run(); + } + + #[test] + fn payload_attestation_message() { + SszStaticHandler::<PayloadAttestationMessage, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<PayloadAttestationMessage, MainnetEthSpec>::gloas_and_later().run(); + } + + #[test] + fn indexed_payload_attestation() { + SszStaticHandler::<IndexedPayloadAttestation<MinimalEthSpec>, MinimalEthSpec>::gloas_and_later() + .run(); + SszStaticHandler::<IndexedPayloadAttestation<MainnetEthSpec>, MainnetEthSpec>::gloas_and_later() + .run(); + } + + #[test] + fn execution_payload_envelope() { + SszStaticHandler::<ExecutionPayloadEnvelope<MinimalEthSpec>, MinimalEthSpec>::gloas_and_later() + .run(); + SszStaticHandler::<ExecutionPayloadEnvelope<MainnetEthSpec>, MainnetEthSpec>::gloas_and_later() + .run(); + } + + #[test] + fn signed_execution_payload_envelope() { + SszStaticHandler::<SignedExecutionPayloadEnvelope<MinimalEthSpec>, MinimalEthSpec>::gloas_and_later() + .run(); + SszStaticHandler::<SignedExecutionPayloadEnvelope<MainnetEthSpec>, MainnetEthSpec>::gloas_and_later() + .run(); + } + + #[test] + fn proposer_preferences() { + SszStaticHandler::<ProposerPreferences, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<ProposerPreferences, MainnetEthSpec>::gloas_and_later().run(); + } + + #[test] + fn signed_proposer_preferences() { + SszStaticHandler::<SignedProposerPreferences, MinimalEthSpec>::gloas_and_later().run(); + SszStaticHandler::<SignedProposerPreferences, MainnetEthSpec>::gloas_and_later().run(); + } } #[test] @@ -770,6 +916,12 @@ fn epoch_processing_pending_balance_deposits() { EpochProcessingHandler::<MainnetEthSpec, PendingBalanceDeposits>::default().run(); } +#[test] +fn epoch_processing_pending_deposits_churn() { + EpochProcessingHandler::<MinimalEthSpec, PendingDepositsChurn>::default().run(); + EpochProcessingHandler::<MainnetEthSpec, PendingDepositsChurn>::default().run(); +} + #[test] fn epoch_processing_pending_consolidations() { EpochProcessingHandler::<MinimalEthSpec, PendingConsolidations>::default().run(); @@ -837,6 +989,18 @@ fn epoch_processing_proposer_lookahead() { EpochProcessingHandler::<MainnetEthSpec, ProposerLookahead>::default().run(); } +#[test] +fn epoch_processing_ptc_window() { + EpochProcessingHandler::<MinimalEthSpec, PtcWindow>::default().run(); + EpochProcessingHandler::<MainnetEthSpec, PtcWindow>::default().run(); +} + +#[test] +fn epoch_processing_builder_pending_payments() { + EpochProcessingHandler::<MinimalEthSpec, BuilderPendingPayments>::default().run(); + EpochProcessingHandler::<MainnetEthSpec, BuilderPendingPayments>::default().run(); +} + #[test] fn fork_upgrade() { ForkHandler::<MinimalEthSpec>::default().run(); @@ -903,6 +1067,18 @@ fn fork_choice_deposit_with_reorg() { // There is no mainnet variant for this test. } +#[test] +fn fork_choice_on_execution_payload_envelope() { + ForkChoiceHandler::<MinimalEthSpec>::new("on_execution_payload_envelope").run(); + ForkChoiceHandler::<MainnetEthSpec>::new("on_execution_payload_envelope").run(); +} + +#[test] +fn fork_choice_get_parent_payload_status() { + ForkChoiceHandler::<MinimalEthSpec>::new("get_parent_payload_status").run(); + ForkChoiceHandler::<MainnetEthSpec>::new("get_parent_payload_status").run(); +} + #[test] fn optimistic_sync() { OptimisticSyncHandler::<MinimalEthSpec>::default().run(); @@ -990,7 +1166,7 @@ fn kzg_inclusion_merkle_proof_validity() { #[test] fn rewards() { - for handler in &["basic", "leak", "random"] { + for handler in &["basic", "leak", "random", "inactivity_scores"] { RewardsHandler::<MinimalEthSpec>::new(handler).run(); RewardsHandler::<MainnetEthSpec>::new(handler).run(); } diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 8c64c7cf37..ed6b5787b5 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -9,8 +9,8 @@ use alloy_signer_local::PrivateKeySigner; use bls::PublicKeyBytes; use execution_layer::test_utils::DEFAULT_GAS_LIMIT; use execution_layer::{ - BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, PayloadAttributes, - PayloadParameters, PayloadStatus, + BlockByNumberQuery, BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, + LATEST_TAG, PayloadAttributes, PayloadParameters, PayloadStatus, }; use fixed_bytes::FixedBytesExtended; use fork_choice::ForkchoiceUpdateParameters; @@ -21,7 +21,7 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use task_executor::TaskExecutor; use tokio::time::sleep; -use types::payload::BlockProductionVersion; +use types::execution::BlockProductionVersion; use types::{ Address, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadHeader, ForkName, Hash256, MainnetEthSpec, Slot, Uint256, @@ -200,6 +200,9 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { pub async fn perform_tests(&self) { self.wait_until_synced().await; + // TODO(gloas): this needs to be for post-Gloas cases + let head_payload_status = fork_choice::PayloadStatus::Pending; + // Create a local signer in case we need to sign transactions locally let private_key_signer: PrivateKeySigner = PRIVATE_KEYS[0].parse().expect("Invalid private key"); @@ -210,25 +213,29 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { let account2 = AlloyAddress::from_slice(&hex::decode(ACCOUNT2).unwrap()); /* - * Read the terminal block hash from both pairs, check it's equal. + * Read the genesis block hash from both pairs, check it's equal. + * Since TTD=0, the genesis block is the terminal PoW block. */ - let terminal_pow_block_hash = self + let genesis_block = self .ee_a .execution_layer - .get_terminal_pow_block_hash(&self.spec, timestamp_now()) + .get_block_by_number(BlockByNumberQuery::Tag(LATEST_TAG)) .await .unwrap() - .unwrap(); + .expect("should have genesis block"); + + let terminal_pow_block_hash = genesis_block.block_hash; assert_eq!( terminal_pow_block_hash, self.ee_b .execution_layer - .get_terminal_pow_block_hash(&self.spec, timestamp_now()) + .get_block_by_number(BlockByNumberQuery::Tag(LATEST_TAG)) .await .unwrap() - .unwrap() + .expect("should have genesis block") + .block_hash ); // Submit transactions before getting payload @@ -304,6 +311,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { .insert_proposer( Slot::new(1), // Insert proposer for the next slot head_root, + fork_choice::PayloadStatus::Pending, proposer_index, PayloadAttributes::new( timestamp, @@ -311,6 +319,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { Address::repeat_byte(42), Some(vec![]), None, + None, ), ) .await; @@ -327,6 +336,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { finalized_block_hash, Slot::new(0), Hash256::zero(), + head_payload_status, ) .await .unwrap(); @@ -355,6 +365,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { suggested_fee_recipient, Some(vec![]), None, + None, ); let payload_parameters = PayloadParameters { @@ -405,6 +416,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -446,6 +458,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -513,6 +526,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { suggested_fee_recipient, Some(vec![]), None, + None, ); let payload_parameters = PayloadParameters { @@ -563,7 +577,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { * * Indicate that the payload is the head of the chain, providing payload attributes. */ - let head_block_hash = valid_payload.block_hash(); + let head_block_hash = second_payload.block_hash(); let finalized_block_hash = ExecutionBlockHash::zero(); // To save sending proposer preparation data, just set the fee recipient // to the fee recipient configured for EE A. @@ -573,13 +587,20 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { Address::repeat_byte(42), Some(vec![]), None, + None, ); let slot = Slot::new(42); let head_block_root = Hash256::repeat_byte(100); let validator_index = 0; self.ee_a .execution_layer - .insert_proposer(slot, head_block_root, validator_index, payload_attributes) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + validator_index, + payload_attributes, + ) .await; let status = self .ee_a @@ -590,6 +611,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -627,6 +649,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -680,6 +703,7 @@ impl<Engine: GenericExecutionEngine> TestRig<Engine> { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); diff --git a/testing/node_test_rig/Cargo.toml b/testing/node_test_rig/Cargo.toml index 0d9db528da..21ec6fac12 100644 --- a/testing/node_test_rig/Cargo.toml +++ b/testing/node_test_rig/Cargo.toml @@ -10,6 +10,7 @@ beacon_node_fallback = { workspace = true } environment = { workspace = true } eth2 = { workspace = true } execution_layer = { workspace = true } +reqwest = { workspace = true } sensitive_url = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } diff --git a/testing/node_test_rig/src/lib.rs b/testing/node_test_rig/src/lib.rs index e49d11ee1e..76a5b7ddb2 100644 --- a/testing/node_test_rig/src/lib.rs +++ b/testing/node_test_rig/src/lib.rs @@ -4,7 +4,8 @@ use beacon_node::ProductionBeaconNode; use environment::RuntimeContext; -use eth2::{BeaconNodeHttpClient, Timeouts, reqwest::ClientBuilder}; +use eth2::{BeaconNodeHttpClient, Timeouts}; +use reqwest::ClientBuilder; use sensitive_url::SensitiveUrl; use std::path::PathBuf; use std::time::Duration; @@ -115,7 +116,7 @@ pub fn testing_client_config() -> ClientConfig { }; // Simulator tests expect historic states to be available for post-run checks. - client_config.chain.reconstruct_historic_states = true; + client_config.chain.archive = true; // Specify a constant count of beacon processor workers. Having this number // too low can cause annoying HTTP timeouts, especially on Github runners diff --git a/testing/simulator/src/basic_sim.rs b/testing/simulator/src/basic_sim.rs index 13bfcb5fc3..79581ee529 100644 --- a/testing/simulator/src/basic_sim.rs +++ b/testing/simulator/src/basic_sim.rs @@ -14,7 +14,6 @@ use rayon::prelude::*; use std::cmp::max; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; use environment::tracing_common; use tracing_subscriber::prelude::*; @@ -175,8 +174,11 @@ pub fn run_basic_sim(matches: &ArgMatches) -> Result<(), String> { let latest_fork_version = spec.electra_fork_version; let latest_fork_start_epoch = ELECTRA_FORK_EPOCH; - spec.seconds_per_slot /= speed_up_factor; - spec.seconds_per_slot = max(1, spec.seconds_per_slot); + let mut slot_duration_ms = spec.get_slot_duration().as_millis() as u64; + slot_duration_ms /= speed_up_factor; + slot_duration_ms = max(1_000, slot_duration_ms); + spec = spec.set_slot_duration_ms::<MinimalEthSpec>(slot_duration_ms); + spec.genesis_delay = genesis_delay; spec.min_genesis_time = 0; spec.min_genesis_active_validator_count = total_validator_count as u64; @@ -188,7 +190,7 @@ pub fn run_basic_sim(matches: &ArgMatches) -> Result<(), String> { let spec = Arc::new(spec); env.eth2_config.spec = spec.clone(); - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let slots_per_epoch = MinimalEthSpec::slots_per_epoch(); let initial_validator_count = spec.min_genesis_active_validator_count as usize; @@ -361,7 +363,7 @@ pub fn run_basic_sim(matches: &ArgMatches) -> Result<(), String> { network_1.add_beacon_node_with_delay( beacon_config.clone(), mock_execution_config.clone(), - END_EPOCH - 1, + END_EPOCH - 3, slot_duration, slots_per_epoch ), diff --git a/testing/simulator/src/checks.rs b/testing/simulator/src/checks.rs index 35200692c3..a2e9ae96b2 100644 --- a/testing/simulator/src/checks.rs +++ b/testing/simulator/src/checks.rs @@ -220,6 +220,8 @@ pub async fn verify_full_sync_aggregates_up_to<E: EthSpec>( Ok(()) } +// TODO(EIP-7732): Add verify_ptc_duties_executed function to verify that PTC duties are being fetched and executed correctly when Gloas fork is enabled + /// Verify that the first merged PoS block got finalized. pub async fn verify_transition_block_finalized<E: EthSpec>( network: LocalNetwork<E>, @@ -463,6 +465,9 @@ pub async fn reconnect_to_execution_layer<E: EthSpec>( } /// Ensure all validators have attested correctly. +/// +/// Checks attestation rewards for head, target, and source. +/// A positive reward indicates a correct vote. pub async fn check_attestation_correctness<E: EthSpec>( network: LocalNetwork<E>, start_epoch: u64, @@ -476,54 +481,49 @@ pub async fn check_attestation_correctness<E: EthSpec>( let remote_node = &network.remote_nodes()?[node_index]; - let results = remote_node - .get_lighthouse_analysis_attestation_performance( - Epoch::new(start_epoch), - Epoch::new(upto_epoch - 2), - "global".to_string(), - ) - .await - .map_err(|e| format!("Unable to get attestation performance: {e}"))?; - - let mut active_successes: f64 = 0.0; let mut head_successes: f64 = 0.0; let mut target_successes: f64 = 0.0; let mut source_successes: f64 = 0.0; - let mut total: f64 = 0.0; - for result in results { - for epochs in result.epochs.values() { + let end_epoch = upto_epoch + .checked_sub(2) + .ok_or_else(|| "upto_epoch must be >= 2 to have attestation rewards".to_string())?; + for epoch in start_epoch..=end_epoch { + let response = remote_node + .post_beacon_rewards_attestations(Epoch::new(epoch), &[]) + .await + .map_err(|e| format!("Unable to get attestation rewards for epoch {epoch}: {e}"))?; + + for reward in &response.data.total_rewards { total += 1.0; - if epochs.active { - active_successes += 1.0; - } - if epochs.head { + // A positive reward means the validator made a correct vote. + if reward.head > 0 { head_successes += 1.0; } - if epochs.target { + if reward.target > 0 { target_successes += 1.0; } - if epochs.source { + if reward.source > 0 { source_successes += 1.0; } } } - let active_percent = active_successes / total * 100.0; + + if total == 0.0 { + return Err("No attestation rewards data found".to_string()); + } + let head_percent = head_successes / total * 100.0; let target_percent = target_successes / total * 100.0; let source_percent = source_successes / total * 100.0; eprintln!("Total Attestations: {}", total); - eprintln!("Active: {}: {}%", active_successes, active_percent); eprintln!("Head: {}: {}%", head_successes, head_percent); eprintln!("Target: {}: {}%", target_successes, target_percent); eprintln!("Source: {}: {}%", source_successes, source_percent); - if active_percent < acceptable_attestation_performance { - return Err("Active percent was below required level".to_string()); - } if head_percent < acceptable_attestation_performance { return Err("Head percent was below required level".to_string()); } diff --git a/testing/simulator/src/fallback_sim.rs b/testing/simulator/src/fallback_sim.rs index 3d9a60abc7..06f4478c5e 100644 --- a/testing/simulator/src/fallback_sim.rs +++ b/testing/simulator/src/fallback_sim.rs @@ -15,12 +15,12 @@ use rayon::prelude::*; use std::cmp::max; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; use tokio::time::sleep; use tracing::Level; use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use types::{Epoch, EthSpec, MinimalEthSpec}; + const END_EPOCH: u64 = 16; const GENESIS_DELAY: u64 = 38; const ALTAIR_FORK_EPOCH: u64 = 0; @@ -179,8 +179,11 @@ pub fn run_fallback_sim(matches: &ArgMatches) -> Result<(), String> { let genesis_delay = GENESIS_DELAY; - spec.seconds_per_slot /= speed_up_factor; - spec.seconds_per_slot = max(1, spec.seconds_per_slot); + let mut slot_duration_ms = spec.get_slot_duration().as_millis() as u64; + slot_duration_ms /= speed_up_factor; + slot_duration_ms = max(1_000, slot_duration_ms); + spec = spec.set_slot_duration_ms::<MinimalEthSpec>(slot_duration_ms); + spec.genesis_delay = genesis_delay; spec.min_genesis_time = 0; spec.min_genesis_active_validator_count = total_validator_count as u64; @@ -193,7 +196,7 @@ pub fn run_fallback_sim(matches: &ArgMatches) -> Result<(), String> { let spec = Arc::new(spec); env.eth2_config.spec = spec.clone(); - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let slots_per_epoch = MinimalEthSpec::slots_per_epoch(); let disconnection_epoch = 1; diff --git a/testing/simulator/src/local_network.rs b/testing/simulator/src/local_network.rs index 58d7e1372f..2beb9c0efc 100644 --- a/testing/simulator/src/local_network.rs +++ b/testing/simulator/src/local_network.rs @@ -73,23 +73,33 @@ fn default_mock_execution_config<E: EthSpec>( if let Some(capella_fork_epoch) = spec.capella_fork_epoch { mock_execution_config.shanghai_time = Some( genesis_time - + spec.seconds_per_slot * E::slots_per_epoch() * capella_fork_epoch.as_u64(), + + (spec.get_slot_duration().as_secs()) + * E::slots_per_epoch() + * capella_fork_epoch.as_u64(), ) } if let Some(deneb_fork_epoch) = spec.deneb_fork_epoch { mock_execution_config.cancun_time = Some( - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * deneb_fork_epoch.as_u64(), + genesis_time + + (spec.get_slot_duration().as_secs()) + * E::slots_per_epoch() + * deneb_fork_epoch.as_u64(), ) } if let Some(electra_fork_epoch) = spec.electra_fork_epoch { mock_execution_config.prague_time = Some( genesis_time - + spec.seconds_per_slot * E::slots_per_epoch() * electra_fork_epoch.as_u64(), + + (spec.get_slot_duration().as_secs()) + * E::slots_per_epoch() + * electra_fork_epoch.as_u64(), ) } if let Some(fulu_fork_epoch) = spec.fulu_fork_epoch { mock_execution_config.osaka_time = Some( - genesis_time + spec.seconds_per_slot * E::slots_per_epoch() * fulu_fork_epoch.as_u64(), + genesis_time + + (spec.get_slot_duration().as_secs()) + * E::slots_per_epoch() + * fulu_fork_epoch.as_u64(), ) } diff --git a/testing/state_transition_vectors/src/exit.rs b/testing/state_transition_vectors/src/exit.rs index f8ece0218f..3b0fe7d8ec 100644 --- a/testing/state_transition_vectors/src/exit.rs +++ b/testing/state_transition_vectors/src/exit.rs @@ -1,4 +1,5 @@ use super::*; +use beacon_chain::test_utils::test_spec; use state_processing::{ BlockProcessingError, BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, per_block_processing, per_block_processing::errors::ExitInvalid, @@ -70,13 +71,13 @@ impl ExitTest { BlockSignatureStrategy::VerifyIndividual, VerifyBlockRoot::True, &mut ctxt, - &E::default_spec(), + &test_spec::<E>(), ) } #[cfg(all(test, not(debug_assertions)))] async fn run(self) -> BeaconState<E> { - let spec = &E::default_spec(); + let spec = &test_spec::<E>(); let expected = self.expected.clone(); assert_eq!(STATE_EPOCH, spec.shard_committee_period); diff --git a/testing/state_transition_vectors/src/main.rs b/testing/state_transition_vectors/src/main.rs index 80c30489b7..6a212f034d 100644 --- a/testing/state_transition_vectors/src/main.rs +++ b/testing/state_transition_vectors/src/main.rs @@ -57,6 +57,7 @@ async fn get_harness<E: EthSpec>( .default_spec() .keypairs(KEYPAIRS[0..validator_count].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); let skip_to_slot = slot - SLOT_OFFSET; if skip_to_slot > Slot::new(0) { diff --git a/testing/validator_test_rig/Cargo.toml b/testing/validator_test_rig/Cargo.toml index f28a423433..2057a9fdc8 100644 --- a/testing/validator_test_rig/Cargo.toml +++ b/testing/validator_test_rig/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } eth2 = { workspace = true } mockito = { workspace = true } regex = { workspace = true } +reqwest = { workspace = true } sensitive_url = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index ff1e772d54..1ecdd85f3b 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -1,7 +1,8 @@ use eth2::types::{GenericResponse, SyncingData}; -use eth2::{BeaconNodeHttpClient, StatusCode, Timeouts}; +use eth2::{BeaconNodeHttpClient, Timeouts}; use mockito::{Matcher, Mock, Server, ServerGuard}; use regex::Regex; +use reqwest::StatusCode; use sensitive_url::SensitiveUrl; use std::marker::PhantomData; use std::str::FromStr; diff --git a/testing/web3signer_tests/Cargo.toml b/testing/web3signer_tests/Cargo.toml index 3ef2e0f7f7..1cac45fe52 100644 --- a/testing/web3signer_tests/Cargo.toml +++ b/testing/web3signer_tests/Cargo.toml @@ -23,7 +23,6 @@ parking_lot = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } slashing_protection = { workspace = true } slot_clock = { workspace = true } ssz_types = { workspace = true } @@ -33,4 +32,5 @@ tokio = { workspace = true } types = { workspace = true } url = { workspace = true } validator_store = { workspace = true } +yaml_serde = { workspace = true } zip = { workspace = true } diff --git a/testing/web3signer_tests/src/lib.rs b/testing/web3signer_tests/src/lib.rs index b37321617b..1e1e83d339 100644 --- a/testing/web3signer_tests/src/lib.rs +++ b/testing/web3signer_tests/src/lib.rs @@ -25,6 +25,7 @@ mod tests { use eth2_keystore::KeystoreBuilder; use eth2_network_config::Eth2NetworkConfig; use fixed_bytes::FixedBytesExtended; + use futures::StreamExt; use initialized_validators::{ InitializedValidators, load_pem_certificate, load_pkcs12_identity, }; @@ -50,7 +51,7 @@ mod tests { use types::{attestation::AttestationBase, *}; use url::Url; use validator_store::{ - Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore, + AttestationToSign, Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore, }; /// If the we are unable to reach the Web3Signer HTTP API within this time out then we will @@ -137,11 +138,7 @@ mod tests { } fn client_identity_path() -> PathBuf { - if cfg!(target_os = "macos") { - tls_dir().join("lighthouse").join("key_legacy.p12") - } else { - tls_dir().join("lighthouse").join("key.p12") - } + tls_dir().join("lighthouse").join("key.p12") } fn client_identity_password() -> String { @@ -213,7 +210,7 @@ mod tests { }; let key_config_file = File::create(keystore_dir.path().join("key-config.yaml")).unwrap(); - serde_yaml::to_writer(key_config_file, &key_config).unwrap(); + yaml_serde::to_writer(key_config_file, &key_config).unwrap(); let tls_keystore_file = tls_dir().join("web3signer").join("key.p12"); let tls_keystore_password_file = tls_dir().join("web3signer").join("password.txt"); @@ -539,6 +536,58 @@ mod tests { } self } + + /// Assert that a slashable attestation fails to be signed locally (empty result) and is + /// either signed or not by the web3signer rig depending on the value of + /// `web3signer_should_sign`. + /// + /// The batch attestation signing API returns an empty result instead of an error for + /// slashable attestations. + pub async fn assert_slashable_attestation_should_sign<F, R>( + self, + case_name: &str, + generate_sig: F, + web3signer_should_sign: bool, + ) -> Self + where + F: Fn(PublicKeyBytes, Arc<LighthouseValidatorStore<TestingSlotClock, E>>) -> R, + R: Future< + Output = Result<Vec<(u64, Attestation<E>)>, lighthouse_validator_store::Error>, + >, + { + for validator_rig in &self.validator_rigs { + let result = + generate_sig(self.validator_pubkey, validator_rig.validator_store.clone()) + .await; + + if !validator_rig.using_web3signer || !web3signer_should_sign { + // For local validators, slashable attestations should return an empty result + // or an error. + match result { + Ok(attestations) => { + assert!( + attestations.is_empty(), + "should not sign slashable {case_name}: expected empty result" + ); + } + Err(ValidatorStoreError::Slashable(_)) => { + // Also acceptable - error indicates slashable + } + Err(e) => { + panic!("unexpected error for slashable {case_name}: {e:?}"); + } + } + } else { + // Web3signer should sign (has its own slashing protection) + let attestations = result.expect("should sign slashable {case_name}"); + assert!( + !attestations.is_empty(), + "web3signer should sign slashable {case_name}" + ); + } + } + self + } } /// Get a generic, arbitrary attestation for signing. @@ -605,12 +654,15 @@ mod tests { }) .await .assert_signatures_match("attestation", |pubkey, validator_store| async move { - let mut attestation = get_attestation(); - validator_store - .sign_attestation(pubkey, 0, &mut attestation, Epoch::new(0)) - .await - .unwrap(); - attestation + let attestation = get_attestation(); + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap().unwrap().pop().unwrap().1 }) .await .assert_signatures_match("signed_aggregate", |pubkey, validator_store| async move { @@ -820,8 +872,6 @@ mod tests { block }; - let current_epoch = Epoch::new(5); - TestingRig::new( network, slashing_protection_config, @@ -830,43 +880,61 @@ mod tests { ) .await .assert_signatures_match("first_attestation", |pubkey, validator_store| async move { - let mut attestation = first_attestation(); - validator_store - .sign_attestation(pubkey, 0, &mut attestation, current_epoch) - .await - .unwrap(); - attestation + let attestation = first_attestation(); + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap().unwrap().pop().unwrap().1 }) .await - .assert_slashable_message_should_sign( + .assert_slashable_attestation_should_sign( "double_vote_attestation", move |pubkey, validator_store| async move { - let mut attestation = double_vote_attestation(); - validator_store - .sign_attestation(pubkey, 0, &mut attestation, current_epoch) - .await + let attestation = double_vote_attestation(); + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) .await - .assert_slashable_message_should_sign( + .assert_slashable_attestation_should_sign( "surrounding_attestation", move |pubkey, validator_store| async move { - let mut attestation = surrounding_attestation(); - validator_store - .sign_attestation(pubkey, 0, &mut attestation, current_epoch) - .await + let attestation = surrounding_attestation(); + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) .await - .assert_slashable_message_should_sign( + .assert_slashable_attestation_should_sign( "surrounded_attestation", move |pubkey, validator_store| async move { - let mut attestation = surrounded_attestation(); - validator_store - .sign_attestation(pubkey, 0, &mut attestation, current_epoch) - .await + let attestation = surrounded_attestation(); + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) diff --git a/testing/web3signer_tests/tls/generate.sh b/testing/web3signer_tests/tls/generate.sh index 3b14dbddba..31900d5d90 100755 --- a/testing/web3signer_tests/tls/generate.sh +++ b/testing/web3signer_tests/tls/generate.sh @@ -1,12 +1,5 @@ #!/bin/bash -# The lighthouse/key_legacy.p12 file is generated specifically for macOS because the default `openssl pkcs12` encoding -# algorithm in OpenSSL v3 is not compatible with the PKCS algorithm used by the Apple Security Framework. The client -# side (using the reqwest crate) relies on the Apple Security Framework to parse PKCS files. -# We don't need to generate web3signer/key_legacy.p12 because the compatibility issue doesn't occur on the web3signer -# side. It seems that web3signer (Java) uses its own implementation to parse PKCS files. -# See https://github.com/sigp/lighthouse/issues/6442#issuecomment-2469252651 - # We specify `-days 825` when generating the certificate files because Apple requires TLS server certificates to have a # validity period of 825 days or fewer. # See https://github.com/sigp/lighthouse/issues/6442#issuecomment-2474979183 @@ -16,5 +9,4 @@ openssl pkcs12 -export -out web3signer/key.p12 -inkey web3signer/key.key -in web cp web3signer/cert.pem lighthouse/web3signer.pem && openssl req -x509 -sha256 -nodes -days 825 -newkey rsa:4096 -keyout lighthouse/key.key -out lighthouse/cert.pem -config lighthouse/config && openssl pkcs12 -export -out lighthouse/key.p12 -inkey lighthouse/key.key -in lighthouse/cert.pem -password pass:$(cat lighthouse/password.txt) && -openssl pkcs12 -export -legacy -out lighthouse/key_legacy.p12 -inkey lighthouse/key.key -in lighthouse/cert.pem -password pass:$(cat lighthouse/password.txt) && openssl x509 -noout -fingerprint -sha256 -inform pem -in lighthouse/cert.pem | cut -b 20-| sed "s/^/lighthouse /" > web3signer/known_clients.txt diff --git a/testing/web3signer_tests/tls/lighthouse/key_legacy.p12 b/testing/web3signer_tests/tls/lighthouse/key_legacy.p12 deleted file mode 100644 index c3394fae9a..0000000000 Binary files a/testing/web3signer_tests/tls/lighthouse/key_legacy.p12 and /dev/null differ diff --git a/validator_client/beacon_node_fallback/Cargo.toml b/validator_client/beacon_node_fallback/Cargo.toml index 481aece48b..bc1ac20d44 100644 --- a/validator_client/beacon_node_fallback/Cargo.toml +++ b/validator_client/beacon_node_fallback/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [dependencies] bls = { workspace = true } clap = { workspace = true } -eth2 = { workspace = true } +eth2 = { workspace = true, features = ["events"] } futures = { workspace = true } itertools = { workspace = true } sensitive_url = { workspace = true } diff --git a/validator_client/beacon_node_fallback/src/beacon_head_monitor.rs b/validator_client/beacon_node_fallback/src/beacon_head_monitor.rs new file mode 100644 index 0000000000..bed107d856 --- /dev/null +++ b/validator_client/beacon_node_fallback/src/beacon_head_monitor.rs @@ -0,0 +1,423 @@ +use crate::BeaconNodeFallback; +use eth2::types::{EventKind, EventTopic, Hash256, SseHead}; +use futures::StreamExt; +use slot_clock::SlotClock; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; +use types::EthSpec; + +type CacheHashMap = HashMap<usize, SseHead>; + +// This is used to send the index derived from `CandidateBeaconNode` to the +// `AttestationService` for further processing +#[derive(Debug)] +pub struct HeadEvent { + pub beacon_node_index: usize, + pub slot: types::Slot, + pub beacon_block_root: Hash256, +} + +/// Cache to maintain the latest head received from each of the beacon nodes +/// in the `BeaconNodeFallback`. +#[derive(Debug)] +pub struct BeaconHeadCache { + cache: RwLock<CacheHashMap>, +} + +impl BeaconHeadCache { + /// Creates a new empty beacon head cache. + pub fn new() -> Self { + Self { + cache: RwLock::new(HashMap::new()), + } + } + + /// Retrieves the cached head for a specific beacon node. + /// Returns `None` if no head has been cached for that node yet. + pub async fn get(&self, beacon_node_index: usize) -> Option<SseHead> { + self.cache.read().await.get(&beacon_node_index).cloned() + } + + /// Stores or updates the head event for a specific beacon node. + /// Replaces any previously cached head for the given node. + pub async fn insert(&self, beacon_node_index: usize, head: SseHead) { + self.cache.write().await.insert(beacon_node_index, head); + } + + /// Checks if the given head is the latest among all cached heads. + /// Returns `true` if the head's slot is >= all cached heads' slots. + pub async fn is_latest(&self, head: &SseHead) -> bool { + let cache = self.cache.read().await; + cache + .values() + .all(|cache_head| head.slot >= cache_head.slot) + } + + /// Clears all cached heads, removing entries for all beacon nodes. + /// Useful when beacon node candidates are refreshed to avoid stale references. + pub async fn purge_cache(&self) { + self.cache.write().await.clear(); + } +} + +impl Default for BeaconHeadCache { + fn default() -> Self { + Self::new() + } +} + +// Runs a non-terminating loop to update the `BeaconHeadCache` with the latest head received +// from the candidate beacon_nodes. This is an attempt to stream events to beacon nodes and +// potential start attestation duties earlier as soon as latest head is receive from any of the +// beacon node in contrast to attest at the 1/3rd mark in the slot. +// +// +// The cache and the candidate BNs list are refresh/purged to avoid dangling reference conditions +// that arise due to `update_candidates_list`. +// +// Starts the service to perpetually stream head events from connected beacon_nodes +pub async fn poll_head_event_from_beacon_nodes<E: EthSpec, T: SlotClock + 'static>( + beacon_nodes: Arc<BeaconNodeFallback<T>>, +) -> Result<(), String> { + let head_cache = beacon_nodes + .beacon_head_cache + .clone() + .ok_or("Unable to start head monitor without beacon_head_cache")?; + let head_monitor_send = beacon_nodes + .head_monitor_send + .clone() + .ok_or("Unable to start head monitor without head_monitor_send")?; + + info!("Starting head monitoring service"); + let candidates = { + let candidates_guard = beacon_nodes.candidates.read().await; + candidates_guard.clone() + }; + + // Clear the cache in case it contains stale data from a previous run. This function gets + // restarted if it fails (see monitoring in `start_fallback_updater_service`). + head_cache.purge_cache().await; + + // Create Vec of streams, which we will select over. + let mut streams = vec![]; + + for candidate in &candidates { + let head_event_stream = candidate + .beacon_node + .get_events::<E>(&[EventTopic::Head]) + .await; + + let head_event_stream = match head_event_stream { + Ok(stream) => stream, + Err(e) => { + warn!(error = ?e, node_index = candidate.index, "Failed to get head event stream"); + continue; + } + }; + + streams.push(head_event_stream.map(|event| (candidate.index, event))); + } + + if streams.is_empty() { + return Err("No beacon nodes available for head event streaming".to_string()); + } + + // Combine streams into a single stream and poll events from any of them. + let mut combined_stream = futures::stream::select_all(streams); + + while let Some((candidate_index, event_result)) = combined_stream.next().await { + match event_result { + Ok(EventKind::Head(head)) => { + debug!( + candidate_index, + block_root = ?head.block, + slot = %head.slot, + "New head from beacon node" + ); + + // Skip optimistic heads - the beacon node can't produce valid + // attestation data when its execution layer is not verified + if head.execution_optimistic { + debug!( + candidate_index, + block_root = ?head.block, + slot = %head.slot, + "Skipping optimistic head" + ); + continue; + } + + head_cache.insert(candidate_index, head.clone()).await; + + if !head_cache.is_latest(&head).await { + debug!( + candidate_index, + block_root = ?head.block, + slot = %head.slot, + "Skipping stale head" + ); + continue; + } + + if head_monitor_send + .send(HeadEvent { + beacon_node_index: candidate_index, + slot: head.slot, + beacon_block_root: head.block, + }) + .await + .is_err() + { + return Err("Head monitoring service channel closed".into()); + } + } + Ok(event) => { + warn!( + event_kind = event.topic_name(), + candidate_index, "Received unexpected event from BN" + ); + continue; + } + Err(e) => { + return Err(format!( + "Head monitoring stream error, node: {candidate_index}, error: {e:?}" + )); + } + } + } + + Err("Stream ended unexpectedly".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::FixedBytesExtended; + use types::{Hash256, Slot}; + + fn create_sse_head(slot: u64, block_root: u8) -> SseHead { + SseHead { + slot: types::Slot::new(slot), + block: Hash256::from_low_u64_be(block_root as u64), + state: Hash256::from_low_u64_be(block_root as u64), + epoch_transition: false, + previous_duty_dependent_root: Hash256::from_low_u64_be(block_root as u64), + current_duty_dependent_root: Hash256::from_low_u64_be(block_root as u64), + execution_optimistic: false, + } + } + + #[tokio::test] + async fn test_beacon_head_cache_insertion_and_retrieval() { + let cache = BeaconHeadCache::new(); + let head_1 = create_sse_head(1, 1); + let head_2 = create_sse_head(2, 2); + + cache.insert(0, head_1.clone()).await; + cache.insert(1, head_2.clone()).await; + + assert_eq!(cache.get(0).await, Some(head_1)); + assert_eq!(cache.get(1).await, Some(head_2)); + assert_eq!(cache.get(2).await, None); + } + + #[tokio::test] + async fn test_beacon_head_cache_update() { + let cache = BeaconHeadCache::new(); + let head_old = create_sse_head(1, 1); + let head_new = create_sse_head(2, 2); + + cache.insert(0, head_old).await; + cache.insert(0, head_new.clone()).await; + + assert_eq!(cache.get(0).await, Some(head_new)); + } + + #[tokio::test] + async fn test_is_latest_with_higher_slot() { + let cache = BeaconHeadCache::new(); + let head_1 = create_sse_head(1, 1); + let head_2 = create_sse_head(2, 2); + let head_3 = create_sse_head(3, 3); + + cache.insert(0, head_1).await; + cache.insert(1, head_2).await; + + assert!(cache.is_latest(&head_3).await); + } + + #[tokio::test] + async fn test_is_latest_with_lower_slot() { + let cache = BeaconHeadCache::new(); + let head_1 = create_sse_head(1, 1); + let head_2 = create_sse_head(2, 2); + let head_older = create_sse_head(1, 99); + + cache.insert(0, head_1).await; + cache.insert(1, head_2).await; + + assert!(!cache.is_latest(&head_older).await); + } + + #[tokio::test] + async fn test_is_latest_with_equal_slot() { + let cache = BeaconHeadCache::new(); + let head_1 = create_sse_head(5, 1); + let head_2 = create_sse_head(5, 2); + let head_equal = create_sse_head(5, 3); + + cache.insert(0, head_1).await; + cache.insert(1, head_2).await; + + assert!(cache.is_latest(&head_equal).await); + } + + #[tokio::test] + async fn test_is_latest_empty_cache() { + let cache = BeaconHeadCache::new(); + let head = create_sse_head(1, 1); + + assert!(cache.is_latest(&head).await); + } + + #[tokio::test] + async fn test_purge_cache_clears_all_entries() { + let cache = BeaconHeadCache::new(); + let head_1 = create_sse_head(1, 1); + let head_2 = create_sse_head(2, 2); + + cache.insert(0, head_1).await; + cache.insert(1, head_2).await; + + assert!(cache.get(0).await.is_some()); + assert!(cache.get(1).await.is_some()); + + cache.purge_cache().await; + + assert!(cache.get(0).await.is_none()); + assert!(cache.get(1).await.is_none()); + } + + #[tokio::test] + async fn test_head_event_creation() { + let block_root = Hash256::from_low_u64_be(99); + let event = HeadEvent { + beacon_node_index: 42, + slot: Slot::new(123), + beacon_block_root: block_root, + }; + assert_eq!(event.beacon_node_index, 42); + assert_eq!(event.slot, Slot::new(123)); + assert_eq!(event.beacon_block_root, block_root); + } + + #[tokio::test] + async fn test_cache_caches_multiple_heads_from_different_nodes() { + let cache = BeaconHeadCache::new(); + let head_1 = create_sse_head(10, 1); + let head_2 = create_sse_head(5, 2); + let head_3 = create_sse_head(8, 3); + + cache.insert(0, head_1.clone()).await; + cache.insert(1, head_2.clone()).await; + cache.insert(2, head_3.clone()).await; + + // Verify all are stored + assert_eq!(cache.get(0).await, Some(head_1)); + assert_eq!(cache.get(1).await, Some(head_2)); + assert_eq!(cache.get(2).await, Some(head_3)); + + // The latest should be slot 10 + let head_10 = create_sse_head(10, 99); + assert!(cache.is_latest(&head_10).await); + + // Anything with slot > 10 should be latest + let head_11 = create_sse_head(11, 99); + assert!(cache.is_latest(&head_11).await); + + // Anything with slot < 10 should not be latest + let head_9 = create_sse_head(9, 99); + assert!(!cache.is_latest(&head_9).await); + } + + #[tokio::test] + async fn test_cache_handles_concurrent_operations() { + let cache = Arc::new(BeaconHeadCache::new()); + let mut handles = vec![]; + + // Spawn multiple tasks that insert heads concurrently + for i in 0..10 { + let cache_clone = cache.clone(); + let handle = tokio::spawn(async move { + let head = create_sse_head(i as u64, (i % 256) as u8); + cache_clone.insert(i, head).await; + }); + handles.push(handle); + } + + // Wait for all tasks to complete + for handle in handles { + handle.await.unwrap(); + } + + // Verify all heads are cached + for i in 0..10 { + assert!(cache.get(i).await.is_some()); + } + } + + #[tokio::test] + async fn test_is_latest_after_cache_updates() { + let cache = BeaconHeadCache::new(); + + // Start with head at slot 5 + let head_5 = create_sse_head(5, 1); + cache.insert(0, head_5.clone()).await; + assert!(cache.is_latest(&head_5).await); + + // Add a higher slot + let head_10 = create_sse_head(10, 2); + cache.insert(1, head_10.clone()).await; + + // head_5 should no longer be latest + assert!(!cache.is_latest(&head_5).await); + // head_10 should be latest + assert!(cache.is_latest(&head_10).await); + + // Add an even higher slot + let head_15 = create_sse_head(15, 3); + cache.insert(2, head_15.clone()).await; + + // head_10 should no longer be latest + assert!(!cache.is_latest(&head_10).await); + // head_15 should be latest + assert!(cache.is_latest(&head_15).await); + } + + #[tokio::test] + async fn test_cache_default_is_empty() { + let cache = BeaconHeadCache::default(); + assert!(cache.get(0).await.is_none()); + assert!(cache.get(999).await.is_none()); + } + + #[tokio::test] + async fn test_is_latest_with_multiple_same_slot_heads() { + let cache = BeaconHeadCache::new(); + let head_slot_5_node1 = create_sse_head(5, 1); + let head_slot_5_node2 = create_sse_head(5, 2); + let head_slot_5_node3 = create_sse_head(5, 3); + + cache.insert(0, head_slot_5_node1).await; + cache.insert(1, head_slot_5_node2).await; + + // All heads with slot 5 should be considered latest + assert!(cache.is_latest(&head_slot_5_node3).await); + + // But heads with slot 4 should not be latest + let head_slot_4 = create_sse_head(4, 4); + assert!(!cache.is_latest(&head_slot_4).await); + } +} diff --git a/validator_client/beacon_node_fallback/src/lib.rs b/validator_client/beacon_node_fallback/src/lib.rs index f2e499f789..bbec7a0ff5 100644 --- a/validator_client/beacon_node_fallback/src/lib.rs +++ b/validator_client/beacon_node_fallback/src/lib.rs @@ -2,7 +2,10 @@ //! "fallback" behaviour; it will try a request on all of the nodes until one or none of them //! succeed. +pub mod beacon_head_monitor; pub mod beacon_node_health; + +use beacon_head_monitor::{BeaconHeadCache, HeadEvent, poll_head_event_from_beacon_nodes}; use beacon_node_health::{ BeaconNodeHealth, BeaconNodeSyncDistanceTiers, ExecutionEngineHealth, IsOptimistic, SyncDistanceTier, check_node_health, @@ -22,7 +25,10 @@ use std::time::{Duration, Instant}; use std::vec::Vec; use strum::VariantNames; use task_executor::TaskExecutor; -use tokio::{sync::RwLock, time::sleep}; +use tokio::{ + sync::{RwLock, mpsc}, + time::sleep, +}; use tracing::{debug, error, warn}; use types::{ChainSpec, Config as ConfigSpec, EthSpec, Slot}; use validator_metrics::{ENDPOINT_ERRORS, ENDPOINT_REQUESTS, inc_counter_vec}; @@ -68,6 +74,31 @@ pub fn start_fallback_updater_service<T: SlotClock + 'static, E: EthSpec>( return Err("Cannot start fallback updater without slot clock"); } + let beacon_nodes_ref = beacon_nodes.clone(); + + // the existence of head_monitor_send is overloaded with the predicate of + // requirement of starting the head monitoring service or not. + if beacon_nodes_ref.head_monitor_send.is_some() { + let head_monitor_future = async move { + loop { + if let Err(error) = + poll_head_event_from_beacon_nodes::<E, T>(beacon_nodes_ref.clone()).await + { + warn!(error, "Head service failed retrying starting next slot"); + + let sleep_time = beacon_nodes_ref + .slot_clock + .as_ref() + .and_then(|slot_clock| slot_clock.duration_to_next_slot()) + .unwrap_or_else(|| beacon_nodes_ref.spec.get_slot_duration()); + sleep(sleep_time).await + } + } + }; + + executor.spawn(head_monitor_future, "head_monitoring"); + } + let future = async move { loop { beacon_nodes.update_all_candidates::<E>().await; @@ -96,12 +127,15 @@ pub fn start_fallback_updater_service<T: SlotClock + 'static, E: EthSpec>( pub enum Error<T> { /// We attempted to contact the node but it failed. RequestFailed(T), + /// The beacon node with the requested index was not available. + CandidateIndexUnknown(usize), } impl<T> Error<T> { pub fn request_failure(&self) -> Option<&T> { match self { Error::RequestFailed(e) => Some(e), + Error::CandidateIndexUnknown(_) => None, } } } @@ -380,6 +414,8 @@ pub struct BeaconNodeFallback<T> { pub candidates: Arc<RwLock<Vec<CandidateBeaconNode>>>, distance_tiers: BeaconNodeSyncDistanceTiers, slot_clock: Option<T>, + beacon_head_cache: Option<Arc<BeaconHeadCache>>, + head_monitor_send: Option<Arc<mpsc::Sender<HeadEvent>>>, broadcast_topics: Vec<ApiTopic>, spec: Arc<ChainSpec>, } @@ -396,6 +432,8 @@ impl<T: SlotClock> BeaconNodeFallback<T> { candidates: Arc::new(RwLock::new(candidates)), distance_tiers, slot_clock: None, + beacon_head_cache: None, + head_monitor_send: None, broadcast_topics, spec, } @@ -410,6 +448,15 @@ impl<T: SlotClock> BeaconNodeFallback<T> { self.slot_clock = Some(slot_clock); } + /// This the head monitor channel that streams events from all the beacon nodes that the + /// validator client is connected in the `BeaconNodeFallback`. This also initializes the + /// beacon_head_cache under the assumption the beacon_head_cache will always be needed when + /// head_monitor_send is set. + pub fn set_head_send(&mut self, head_monitor_send: Arc<mpsc::Sender<HeadEvent>>) { + self.head_monitor_send = Some(head_monitor_send); + self.beacon_head_cache = Some(Arc::new(BeaconHeadCache::new())); + } + /// The count of candidates, regardless of their state. pub async fn num_total(&self) -> usize { self.candidates.read().await.len() @@ -476,9 +523,9 @@ impl<T: SlotClock> BeaconNodeFallback<T> { } let timeouts: Timeouts = if new_list.len() == 1 || use_long_timeouts { - Timeouts::set_all(Duration::from_secs(self.spec.seconds_per_slot)) + Timeouts::set_all(self.spec.get_slot_duration()) } else { - Timeouts::use_optimized_timeouts(Duration::from_secs(self.spec.seconds_per_slot)) + Timeouts::use_optimized_timeouts(self.spec.get_slot_duration()) }; let new_candidates: Vec<CandidateBeaconNode> = new_list @@ -493,6 +540,10 @@ impl<T: SlotClock> BeaconNodeFallback<T> { let mut candidates = self.candidates.write().await; *candidates = new_candidates; + if let Some(cache) = &self.beacon_head_cache { + cache.purge_cache().await; + } + Ok(new_list) } @@ -646,6 +697,32 @@ impl<T: SlotClock> BeaconNodeFallback<T> { Err(Errors(errors)) } + /// Try `func` on a specific beacon node by index. + /// + /// Returns immediately if the preferred node succeeds, otherwise return an error. + pub async fn run_on_candidate_index<F, O, Err, R>( + &self, + candidate_index: usize, + func: F, + ) -> Result<O, Error<Err>> + where + F: Fn(BeaconNodeHttpClient) -> R + Clone, + R: Future<Output = Result<O, Err>>, + Err: Debug, + { + // Find the requested beacon node or return an error. + let candidates = self.candidates.read().await; + let Some(candidate) = candidates.iter().find(|c| c.index == candidate_index) else { + return Err(Error::CandidateIndexUnknown(candidate_index)); + }; + let candidate_node = candidate.beacon_node.clone(); + drop(candidates); + + Self::run_on_candidate(candidate_node, &func) + .await + .map_err(|(_, err)| err) + } + /// Run the future `func` on `candidate` while reporting metrics. async fn run_on_candidate<F, R, Err, O>( candidate: BeaconNodeHttpClient, @@ -1080,4 +1157,60 @@ mod tests { mock1.expect(3).assert(); mock2.expect(3).assert(); } + + #[tokio::test] + async fn run_on_candidate_index_success() { + let spec = Arc::new(MainnetEthSpec::default_spec()); + let (mut mock_beacon_node_1, beacon_node_1) = new_mock_beacon_node(0, &spec).await; + let (mut mock_beacon_node_2, beacon_node_2) = new_mock_beacon_node(1, &spec).await; + let (mut mock_beacon_node_3, beacon_node_3) = new_mock_beacon_node(2, &spec).await; + + let beacon_node_fallback = create_beacon_node_fallback( + vec![beacon_node_1, beacon_node_2, beacon_node_3], + vec![], + spec.clone(), + ); + + let mock1 = mock_beacon_node_1.mock_offline_node(); + let _mock2 = mock_beacon_node_2.mock_online_node(); + let mock3 = mock_beacon_node_3.mock_online_node(); + + // Request with preferred_index=1 (beacon_node_2) + let result = beacon_node_fallback + .run_on_candidate_index(1, |client| async move { client.get_node_version().await }) + .await; + + // Should succeed since beacon_node_2 is online + assert!(result.is_ok()); + + // mock1 should not be called since preferred node succeeds + mock1.expect(0).assert(); + mock3.expect(0).assert(); + } + + #[tokio::test] + async fn run_on_candidate_index_error() { + let spec = Arc::new(MainnetEthSpec::default_spec()); + let (mut mock_beacon_node_1, beacon_node_1) = new_mock_beacon_node(0, &spec).await; + let (mut mock_beacon_node_2, beacon_node_2) = new_mock_beacon_node(1, &spec).await; + let (mut mock_beacon_node_3, beacon_node_3) = new_mock_beacon_node(2, &spec).await; + + let beacon_node_fallback = create_beacon_node_fallback( + vec![beacon_node_1, beacon_node_2, beacon_node_3], + vec![], + spec.clone(), + ); + + let _mock1 = mock_beacon_node_1.mock_online_node(); + let _mock2 = mock_beacon_node_2.mock_offline_node(); + let _mock3 = mock_beacon_node_3.mock_offline_node(); + + // Request with preferred_index=1 (beacon_node_2), but it's offline + let result = beacon_node_fallback + .run_on_candidate_index(1, |client| async move { client.get_node_version().await }) + .await; + + // Should fail. + assert!(result.is_err()); + } } diff --git a/validator_client/http_api/Cargo.toml b/validator_client/http_api/Cargo.toml index 2bd57867ac..e334ab9db0 100644 --- a/validator_client/http_api/Cargo.toml +++ b/validator_client/http_api/Cargo.toml @@ -8,14 +8,17 @@ authors = ["Sigma Prime <contact@sigmaprime.io>"] name = "validator_http_api" path = "src/lib.rs" +[features] +testing = ["dep:deposit_contract", "dep:doppelganger_service", "dep:tempfile"] + [dependencies] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } -deposit_contract = { workspace = true } +deposit_contract = { workspace = true, optional = true } directory = { workspace = true } dirs = { workspace = true } -doppelganger_service = { workspace = true } +doppelganger_service = { workspace = true, optional = true } eth2 = { workspace = true, features = ["lighthouse"] } eth2_keystore = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -38,7 +41,7 @@ slot_clock = { workspace = true } sysinfo = { workspace = true } system_health = { workspace = true } task_executor = { workspace = true } -tempfile = { workspace = true } +tempfile = { workspace = true, optional = true } tokio = { workspace = true } tokio-stream = { workspace = true } tracing = { workspace = true } @@ -53,7 +56,10 @@ warp_utils = { workspace = true } zeroize = { workspace = true } [dev-dependencies] +deposit_contract = { workspace = true } +doppelganger_service = { workspace = true } futures = { workspace = true } itertools = { workspace = true } rand = { workspace = true, features = ["small_rng"] } ssz_types = { workspace = true } +tempfile = { workspace = true } diff --git a/validator_client/http_api/src/keystores.rs b/validator_client/http_api/src/keystores.rs index 18accf0d5a..9004bcbd62 100644 --- a/validator_client/http_api/src/keystores.rs +++ b/validator_client/http_api/src/keystores.rs @@ -102,10 +102,8 @@ pub fn import<T: SlotClock + 'static, E: EthSpec>( // Import each keystore. Some keystores may fail to be imported, so we record a status for each. let mut statuses = Vec::with_capacity(request.keystores.len()); - for (KeystoreJsonStr(keystore), password) in request - .keystores - .into_iter() - .zip(request.passwords.into_iter()) + for (KeystoreJsonStr(keystore), password) in + request.keystores.into_iter().zip(request.passwords) { let pubkey_str = keystore.pubkey().to_string(); diff --git a/validator_client/http_api/src/lib.rs b/validator_client/http_api/src/lib.rs index a35b4ec6c6..8e9c077e57 100644 --- a/validator_client/http_api/src/lib.rs +++ b/validator_client/http_api/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "testing")] +pub mod test_utils; + mod api_secret; mod create_signed_voluntary_exit; mod create_validator; @@ -6,7 +9,6 @@ mod keystores; mod remotekeys; mod tests; -pub mod test_utils; pub use api_secret::PK_FILENAME; use graffiti::{delete_graffiti, get_graffiti, set_graffiti}; diff --git a/validator_client/http_api/src/tests/keystores.rs b/validator_client/http_api/src/tests/keystores.rs index eeb3cd94de..eb35075526 100644 --- a/validator_client/http_api/src/tests/keystores.rs +++ b/validator_client/http_api/src/tests/keystores.rs @@ -9,6 +9,7 @@ use eth2::lighthouse_vc::{ types::Web3SignerValidatorRequest, }; use fixed_bytes::FixedBytesExtended; +use futures::StreamExt; use itertools::Itertools; use lighthouse_validator_store::DEFAULT_GAS_LIMIT; use rand::rngs::StdRng; @@ -19,6 +20,7 @@ use std::{collections::HashMap, path::Path}; use tokio::runtime::Handle; use typenum::Unsigned; use types::{Address, attestation::AttestationBase}; +use validator_store::AttestationToSign; use validator_store::ValidatorStore; use zeroize::Zeroizing; @@ -1099,14 +1101,23 @@ async fn generic_migration_test( check_keystore_import_response(&import_res, all_imported(keystores.len())); // Sign attestations on VC1. - for (validator_index, mut attestation) in first_vc_attestations { + for (validator_index, attestation) in first_vc_attestations { let public_key = keystore_pubkey(&keystores[validator_index]); - let current_epoch = attestation.data().target.epoch; - tester1 + let stream = tester1 .validator_store - .sign_attestation(public_key, 0, &mut attestation, current_epoch) - .await - .unwrap(); + .sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey: public_key, + validator_committee_index: 0, + attestation: attestation.clone(), + }]); + tokio::pin!(stream); + let safe_attestations = stream.next().await.unwrap().unwrap(); + assert_eq!(safe_attestations.len(), 1); + // Compare data only, ignoring signatures which are added during signing. + assert_eq!(safe_attestations[0].1.data(), attestation.data()); + // Check that the signature is non-zero. + assert!(!safe_attestations[0].1.signature().is_infinity()); } // Delete the selected keys from VC1. @@ -1178,16 +1189,34 @@ async fn generic_migration_test( check_keystore_import_response(&import_res, all_imported(import_indices.len())); // Sign attestations on the second VC. - for (validator_index, mut attestation, should_succeed) in second_vc_attestations { + for (validator_index, attestation, should_succeed) in second_vc_attestations { let public_key = keystore_pubkey(&keystores[validator_index]); - let current_epoch = attestation.data().target.epoch; - match tester2 + let stream = tester2 .validator_store - .sign_attestation(public_key, 0, &mut attestation, current_epoch) - .await - { - Ok(()) => assert!(should_succeed), - Err(e) => assert!(!should_succeed, "{:?}", e), + .sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey: public_key, + validator_committee_index: 0, + attestation: attestation.clone(), + }]); + tokio::pin!(stream); + let result = stream.next().await.unwrap(); + match result { + Ok(safe_attestations) => { + if should_succeed { + // Compare data only, ignoring signatures which are added during signing. + assert_eq!(safe_attestations.len(), 1); + assert_eq!(safe_attestations[0].1.data(), attestation.data()); + // Check that the signature is non-zero. + assert!(!safe_attestations[0].1.signature().is_infinity()); + } else { + assert!(safe_attestations.is_empty()); + } + } + Err(_) => { + // Doppelganger protected or other error. + assert!(!should_succeed); + } } } }) @@ -1313,11 +1342,16 @@ async fn delete_concurrent_with_signing() { let handle = handle.spawn(async move { for j in 0..num_attestations { - let mut att = make_attestation(j, j + 1); - for public_key in thread_pubkeys.iter() { - let _ = validator_store - .sign_attestation(*public_key, 0, &mut att, Epoch::new(j + 1)) - .await; + let att = make_attestation(j, j + 1); + for (validator_index, public_key) in thread_pubkeys.iter().enumerate() { + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: validator_index as u64, + pubkey: *public_key, + validator_committee_index: 0, + attestation: att.clone(), + }]); + tokio::pin!(stream); + let _ = stream.next().await; } } }); diff --git a/validator_client/http_metrics/src/lib.rs b/validator_client/http_metrics/src/lib.rs index 70b447a493..a6624b4f44 100644 --- a/validator_client/http_metrics/src/lib.rs +++ b/validator_client/http_metrics/src/lib.rs @@ -197,6 +197,16 @@ pub fn gather_prometheus_metrics<E: EthSpec>( &[NEXT_EPOCH], duties_service.attester_count(next_epoch) as i64, ); + set_int_gauge( + &PTC_COUNT, + &[CURRENT_EPOCH], + duties_service.ptc_count(current_epoch) as i64, + ); + set_int_gauge( + &PTC_COUNT, + &[NEXT_EPOCH], + duties_service.ptc_count(next_epoch) as i64, + ); } } diff --git a/validator_client/initialized_validators/Cargo.toml b/validator_client/initialized_validators/Cargo.toml index 8b2ae62aea..53191ffe1e 100644 --- a/validator_client/initialized_validators/Cargo.toml +++ b/validator_client/initialized_validators/Cargo.toml @@ -12,7 +12,9 @@ eth2_keystore = { workspace = true } filesystem = { workspace = true } lockfile = { workspace = true } metrics = { workspace = true } +p12-keystore = "0.2" parking_lot = { workspace = true } +pem = "3" rand = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/validator_client/initialized_validators/src/key_cache.rs b/validator_client/initialized_validators/src/key_cache.rs index b600013c8b..c2f60acc27 100644 --- a/validator_client/initialized_validators/src/key_cache.rs +++ b/validator_client/initialized_validators/src/key_cache.rs @@ -1,7 +1,7 @@ use account_utils::write_file_via_temporary; use bls::{Keypair, PublicKey}; use eth2_keystore::json_keystore::{ - Aes128Ctr, ChecksumModule, Cipher, CipherModule, Crypto, EmptyMap, EmptyString, KdfModule, + Aes128Ctr, ChecksumModule, Cipher, CipherModule, Crypto, EmptyMap, EmptyString, Kdf, KdfModule, Sha256Checksum, }; use eth2_keystore::{ @@ -65,10 +65,14 @@ impl KeyCache { } pub fn init_crypto() -> Crypto { + Self::build_crypto(default_kdf) + } + + fn build_crypto(kdf_fn: fn(Vec<u8>) -> Kdf) -> Crypto { let salt = rand::rng().random::<[u8; SALT_SIZE]>(); let iv = rand::rng().random::<[u8; IV_SIZE]>().to_vec().into(); - let kdf = default_kdf(salt.to_vec()); + let kdf = kdf_fn(salt.to_vec()); let cipher = Cipher::Aes128Ctr(Aes128Ctr { iv }); Crypto { @@ -116,7 +120,11 @@ impl KeyCache { } fn encrypt(&mut self) -> Result<(), Error> { - self.crypto = Self::init_crypto(); + self.encrypt_with(default_kdf) + } + + fn encrypt_with(&mut self, kdf_fn: fn(Vec<u8>) -> Kdf) -> Result<(), Error> { + self.crypto = Self::build_crypto(kdf_fn); let secret_map: SerializedKeyMap = self .pairs .iter() @@ -268,7 +276,19 @@ pub enum Error { #[cfg(test)] mod tests { use super::*; - use eth2_keystore::json_keystore::HexBytes; + use eth2_keystore::json_keystore::{HexBytes, Scrypt}; + + /// Scrypt with minimal cost (n=1024) for fast test execution. + /// Production uses n=262144 which takes ~45s per derivation. + fn insecure_kdf(salt: Vec<u8>) -> Kdf { + Kdf::Scrypt(Scrypt { + dklen: 32, + n: 1024, + p: 1, + r: 8, + salt: salt.into(), + }) + } #[tokio::test] async fn test_serialization() { @@ -302,7 +322,7 @@ mod tests { key_cache.add(keypair.clone(), uuid, password.clone()); } - key_cache.encrypt().unwrap(); + key_cache.encrypt_with(insecure_kdf).unwrap(); key_cache.state = State::DecryptedAndSaved; assert_eq!(&key_cache.uuids, &uuids); diff --git a/validator_client/initialized_validators/src/lib.rs b/validator_client/initialized_validators/src/lib.rs index db6d03174d..8928e4f508 100644 --- a/validator_client/initialized_validators/src/lib.rs +++ b/validator_client/initialized_validators/src/lib.rs @@ -397,6 +397,7 @@ pub fn load_pem_certificate<P: AsRef<Path>>(pem_path: P) -> Result<Certificate, Certificate::from_pem(&buf).map_err(Error::InvalidWeb3SignerRootCertificate) } +// Read a PKCS12 identity certificate and parse it into a PEM certificate. pub fn load_pkcs12_identity<P: AsRef<Path>>( pkcs12_path: P, password: &str, @@ -406,7 +407,29 @@ pub fn load_pkcs12_identity<P: AsRef<Path>>( .map_err(Error::InvalidWeb3SignerClientIdentityCertificateFile)? .read_to_end(&mut buf) .map_err(Error::InvalidWeb3SignerClientIdentityCertificateFile)?; - Identity::from_pkcs12_der(&buf, password) + + let keystore = p12_keystore::KeyStore::from_pkcs12(&buf, password).map_err(|e| { + Error::InvalidWeb3SignerClientIdentityCertificateFile(io::Error::new( + io::ErrorKind::InvalidData, + format!("PKCS12 parse error: {e:?}"), + )) + })?; + + let (_alias, key_chain) = keystore + .private_key_chain() + .ok_or(Error::MissingWeb3SignerClientIdentityCertificateFile)?; + + let key_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", key_chain.key())); + let certs_pem: String = key_chain + .chain() + .iter() + .map(|cert| pem::encode(&pem::Pem::new("CERTIFICATE", cert.as_der()))) + .collect::<Vec<_>>() + .join("\n"); + + let combined_pem = format!("{key_pem}\n{certs_pem}"); + + Identity::from_pem(combined_pem.as_bytes()) .map_err(Error::InvalidWeb3SignerClientIdentityCertificate) } diff --git a/validator_client/lighthouse_validator_store/Cargo.toml b/validator_client/lighthouse_validator_store/Cargo.toml index 01c7616be1..55d5f1cf32 100644 --- a/validator_client/lighthouse_validator_store/Cargo.toml +++ b/validator_client/lighthouse_validator_store/Cargo.toml @@ -12,6 +12,7 @@ doppelganger_service = { workspace = true } either = { workspace = true } environment = { workspace = true } eth2 = { workspace = true } +futures = { workspace = true } initialized_validators = { workspace = true } logging = { workspace = true } parking_lot = { workspace = true } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index e88b07adf5..f4e7716e57 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -2,6 +2,7 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition} use bls::{PublicKeyBytes, Signature}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; +use futures::{Stream, future::join_all, stream}; use initialized_validators::InitializedValidators; use logging::crit; use parking_lot::{Mutex, RwLock}; @@ -9,25 +10,27 @@ use serde::{Deserialize, Serialize}; use signing_method::Error as SigningError; use signing_method::{SignableMessage, SigningContext, SigningMethod}; use slashing_protection::{ - InterchangeError, NotSafe, Safe, SlashingDatabase, interchange::Interchange, + CheckSlashability, InterchangeError, NotSafe, Safe, SlashingDatabase, interchange::Interchange, }; use slot_clock::SlotClock; use std::marker::PhantomData; use std::path::Path; use std::sync::Arc; use task_executor::TaskExecutor; -use tracing::{error, info, instrument, warn}; +use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, - ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, Fork, Graffiti, Hash256, - InclusionList, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, - SignedContributionAndProof, SignedInclusionList, SignedRoot, SignedValidatorRegistrationData, + ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, + FullPayload, Graffiti, Hash256, InclusionList, PayloadAttestationData, PayloadAttestationMessage, + SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, + SignedExecutionPayloadEnvelope, SignedInclusionList, SignedRoot, SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString, }; use validator_store::{ - DoppelgangerStatus, Error as ValidatorStoreError, ProposalData, SignedBlock, UnsignedBlock, + AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, + Error as ValidatorStoreError, ProposalData, SignedBlock, SyncMessageToSign, UnsignedBlock, ValidatorStore, }; @@ -52,7 +55,7 @@ pub struct Config { /// Number of epochs of slashing protection history to keep. /// /// This acts as a maximum safe-guard against clock drift. -const SLASHING_PROTECTION_HISTORY_EPOCHS: u64 = 512; +const SLASHING_PROTECTION_HISTORY_EPOCHS: u64 = 1; /// Currently used as the default gas limit in execution clients. /// @@ -556,6 +559,253 @@ impl<T: SlotClock + 'static, E: EthSpec> LighthouseValidatorStore<T, E> { signature, }) } + + /// Sign an attestation without performing any slashing protection checks. + /// + /// THIS METHOD IS DANGEROUS AND SHOULD ONLY BE USED INTERNALLY IMMEDIATELY PRIOR TO A + /// SLASHING PROTECTION CHECK. See `slashing_protect_attestations`. + /// + /// This method DOES perform doppelganger protection checks. + #[instrument(level = "debug", skip_all)] + async fn sign_attestation_no_slashing_protection( + &self, + validator_pubkey: PublicKeyBytes, + validator_committee_position: usize, + attestation: &mut Attestation<E>, + ) -> Result<(), Error> { + // Get the signing method and check doppelganger protection. + let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; + + // Sign the attestation. + let signing_epoch = attestation.data().target.epoch; + let signing_context = self.signing_context(Domain::BeaconAttester, signing_epoch); + + let signature = signing_method + .get_signature::<E, BlindedPayload<E>>( + SignableMessage::AttestationData(attestation.data()), + signing_context, + &self.spec, + &self.task_executor, + ) + .await?; + attestation + .add_signature(&signature, validator_committee_position) + .map_err(Error::UnableToSignAttestation)?; + + Ok(()) + } + + /// Provide slashing protection for `attestations`, safely updating the slashing protection DB. + /// + /// Return a vec of safe attestations which have passed slashing protection. Unsafe attestations + /// will be dropped and result in warning logs. + /// + /// This method SKIPS slashing protection for web3signer validators that have slashing + /// protection disabled at the Lighthouse layer. It is up to the user to ensure slashing + /// protection is enabled in web3signer instead. + #[instrument(level = "debug", skip_all)] + fn slashing_protect_attestations( + &self, + attestations: Vec<(u64, Attestation<E>, PublicKeyBytes)>, + ) -> Result<Vec<(u64, Attestation<E>)>, Error> { + let mut safe_attestations = Vec::with_capacity(attestations.len()); + let mut attestations_to_check = Vec::with_capacity(attestations.len()); + + // Split attestations into de-facto safe attestations (checked by web3signer's slashing + // protection) and ones requiring checking against the slashing protection DB. + // + // All attestations are added to `attestation_to_check`, with skipped attestations having + // `CheckSlashability::No`. + for (_, attestation, validator_pubkey) in &attestations { + let signing_method = self.doppelganger_checked_signing_method(*validator_pubkey)?; + let signing_epoch = attestation.data().target.epoch; + let signing_context = self.signing_context(Domain::BeaconAttester, signing_epoch); + let domain_hash = signing_context.domain_hash(&self.spec); + + let check_slashability = if signing_method + .requires_local_slashing_protection(self.enable_web3signer_slashing_protection) + { + CheckSlashability::Yes + } else { + CheckSlashability::No + }; + attestations_to_check.push(( + attestation.data(), + validator_pubkey, + domain_hash, + check_slashability, + )); + } + + // Batch check the attestations against the slashing protection DB while preserving the + // order so we can zip the results against the original vec. + // + // If the DB transaction fails then we consider the entire batch slashable and discard it. + let results = self + .slashing_protection + .check_and_insert_attestations(&attestations_to_check) + .map_err(Error::Slashable)?; + + for ((validator_index, attestation, validator_pubkey), slashing_status) in + attestations.into_iter().zip(results.into_iter()) + { + match slashing_status { + Ok(Safe::Valid) => { + safe_attestations.push((validator_index, attestation)); + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, + &[validator_metrics::SUCCESS], + ); + } + Ok(Safe::SameData) => { + warn!("Skipping previously signed attestation"); + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, + &[validator_metrics::SAME_DATA], + ); + } + Err(NotSafe::UnregisteredValidator(pk)) => { + warn!( + msg = "Carefully consider running with --init-slashing-protection (see --help)", + public_key = ?pk, + "Not signing attestation for unregistered validator" + ); + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, + &[validator_metrics::UNREGISTERED], + ); + } + Err(e) => { + warn!( + slot = %attestation.data().slot, + block_root = ?attestation.data().beacon_block_root, + public_key = ?validator_pubkey, + error = ?e, + "Skipping signing of slashable attestation" + ); + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, + &[validator_metrics::SLASHABLE], + ); + } + } + } + + Ok(safe_attestations) + } + + /// Signs an `AggregateAndProof` for a given validator. + /// + /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be + /// modified by actors other than the signing validator. + pub async fn produce_signed_aggregate_and_proof( + &self, + validator_pubkey: PublicKeyBytes, + aggregator_index: u64, + aggregate: Attestation<E>, + selection_proof: SelectionProof, + ) -> Result<SignedAggregateAndProof<E>, Error> { + let signing_epoch = aggregate.data().target.epoch; + let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch); + + let message = + AggregateAndProof::from_attestation(aggregator_index, aggregate, selection_proof); + + let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature::<E, BlindedPayload<E>>( + SignableMessage::SignedAggregateAndProof(message.to_ref()), + signing_context, + &self.spec, + &self.task_executor, + ) + .await?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_AGGREGATES_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedAggregateAndProof::from_aggregate_and_proof( + message, signature, + )) + } + + pub async fn produce_sync_committee_signature( + &self, + slot: Slot, + beacon_block_root: Hash256, + validator_index: u64, + validator_pubkey: &PublicKeyBytes, + ) -> Result<SyncCommitteeMessage, Error> { + let signing_epoch = slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch); + + // Bypass `with_validator_signing_method`: sync committee messages are not slashable. + let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?; + + let signature = signing_method + .get_signature::<E, BlindedPayload<E>>( + SignableMessage::SyncCommitteeSignature { + beacon_block_root, + slot, + }, + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SyncCommitteeMessage { + slot, + beacon_block_root, + validator_index, + signature, + }) + } + + pub async fn produce_signed_contribution_and_proof( + &self, + aggregator_index: u64, + aggregator_pubkey: PublicKeyBytes, + contribution: SyncCommitteeContribution<E>, + selection_proof: SyncSelectionProof, + ) -> Result<SignedContributionAndProof<E>, Error> { + let signing_epoch = contribution.slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch); + + // Bypass `with_validator_signing_method`: sync committee messages are not slashable. + let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?; + + let message = ContributionAndProof { + aggregator_index, + contribution, + selection_proof: selection_proof.into(), + }; + + let signature = signing_method + .get_signature::<E, BlindedPayload<E>>( + SignableMessage::SignedContributionAndProof(&message), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedContributionAndProof { message, signature }) + } } impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore for LighthouseValidatorStore<T, E> { @@ -747,103 +997,90 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore for LighthouseValidatorS } } - #[instrument(skip_all)] - async fn sign_attestation( - &self, - validator_pubkey: PublicKeyBytes, - validator_committee_position: usize, - attestation: &mut Attestation<E>, - current_epoch: Epoch, - ) -> Result<(), Error> { - // Make sure the target epoch is not higher than the current epoch to avoid potential attacks. - if attestation.data().target.epoch > current_epoch { - return Err(Error::GreaterThanCurrentEpoch { - epoch: attestation.data().target.epoch, - current_epoch, - }); - } + fn sign_attestations( + self: &Arc<Self>, + mut attestations: Vec<AttestationToSign<E>>, + ) -> impl Stream<Item = Result<Vec<(u64, Attestation<E>)>, Error>> + Send { + let store = self.clone(); + stream::once(async move { + // Sign all attestations concurrently. + let signing_futures = attestations.iter_mut().map( + |AttestationToSign { + pubkey, + validator_committee_index, + attestation, + .. + }| { + let pubkey = *pubkey; + let validator_committee_index = *validator_committee_index; + let store = store.clone(); + async move { + store + .sign_attestation_no_slashing_protection( + pubkey, + validator_committee_index, + attestation, + ) + .await + } + }, + ); - // Get the signing method and check doppelganger protection. - let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; + // Execute all signing in parallel. + let results: Vec<_> = join_all(signing_futures).await; - // Checking for slashing conditions. - let signing_epoch = attestation.data().target.epoch; - let signing_context = self.signing_context(Domain::BeaconAttester, signing_epoch); - let domain_hash = signing_context.domain_hash(&self.spec); - let slashing_status = if signing_method - .requires_local_slashing_protection(self.enable_web3signer_slashing_protection) - { - self.slashing_protection.check_and_insert_attestation( - &validator_pubkey, - attestation.data(), - domain_hash, - ) - } else { - Ok(Safe::Valid) - }; - - match slashing_status { - // We can safely sign this attestation. - Ok(Safe::Valid) => { - let signature = signing_method - .get_signature::<E, BlindedPayload<E>>( - SignableMessage::AttestationData(attestation.data()), - signing_context, - &self.spec, - &self.task_executor, - ) - .await?; - attestation - .add_signature(&signature, validator_committee_position) - .map_err(Error::UnableToSignAttestation)?; - - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, - &[validator_metrics::SUCCESS], - ); - - Ok(()) + // Collect successfully signed attestations and log errors. + let mut signed_attestations = Vec::with_capacity(attestations.len()); + for (result, att) in results.into_iter().zip(attestations) { + match result { + Ok(()) => { + signed_attestations.push(( + att.validator_index, + att.attestation, + att.pubkey, + )); + } + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + warn!( + info = "a validator may have recently been removed from this VC", + ?pubkey, + "Missing pubkey for attestation" + ); + } + Err(e) => { + crit!( + error = ?e, + "Failed to sign attestation" + ); + } + } } - Ok(Safe::SameData) => { - warn!("Skipping signing of previously signed attestation"); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, - &[validator_metrics::SAME_DATA], - ); - Err(Error::SameData) + + if signed_attestations.is_empty() { + return Ok(vec![]); } - Err(NotSafe::UnregisteredValidator(pk)) => { - warn!( - msg = "Carefully consider running with --init-slashing-protection (see --help)", - public_key = format!("{:?}", pk), - "Not signing attestation for unregistered validator" - ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, - &[validator_metrics::UNREGISTERED], - ); - Err(Error::Slashable(NotSafe::UnregisteredValidator(pk))) - } - Err(e) => { - crit!( - attestation = format!("{:?}", attestation.data()), - error = format!("{:?}", e), - "Not signing slashable attestation" - ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_ATTESTATIONS_TOTAL, - &[validator_metrics::SLASHABLE], - ); - Err(Error::Slashable(e)) - } - } + + // Check slashing protection and insert into database. Use a dedicated blocking + // thread to avoid clogging the async executor with blocking database I/O. + let validator_store = store.clone(); + let safe_attestations = store + .task_executor + .spawn_blocking_handle( + move || validator_store.slashing_protect_attestations(signed_attestations), + "slashing_protect_attestations", + ) + .ok_or(Error::ExecutorError)? + .await + .map_err(|_| Error::ExecutorError)??; + Ok(safe_attestations) + }) } async fn sign_validator_registration_data( &self, validator_registration_data: ValidatorRegistrationData, ) -> Result<SignedValidatorRegistrationData, Error> { - let domain_hash = self.spec.get_builder_domain(); + let domain_hash = self.spec.get_builder_application_domain(); let signing_root = validator_registration_data.signing_root(domain_hash); let signing_method = @@ -868,43 +1105,6 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore for LighthouseValidatorS }) } - /// Signs an `AggregateAndProof` for a given validator. - /// - /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be - /// modified by actors other than the signing validator. - async fn produce_signed_aggregate_and_proof( - &self, - validator_pubkey: PublicKeyBytes, - aggregator_index: u64, - aggregate: Attestation<E>, - selection_proof: SelectionProof, - ) -> Result<SignedAggregateAndProof<E>, Error> { - let signing_epoch = aggregate.data().target.epoch; - let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch); - - let message = - AggregateAndProof::from_attestation(aggregator_index, aggregate, selection_proof); - - let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; - let signature = signing_method - .get_signature::<E, BlindedPayload<E>>( - SignableMessage::SignedAggregateAndProof(message.to_ref()), - signing_context, - &self.spec, - &self.task_executor, - ) - .await?; - - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_AGGREGATES_TOTAL, - &[validator_metrics::SUCCESS], - ); - - Ok(SignedAggregateAndProof::from_aggregate_and_proof( - message, signature, - )) - } - /// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to /// `validator_pubkey`. async fn produce_selection_proof( @@ -979,80 +1179,172 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore for LighthouseValidatorS Ok(signature.into()) } - async fn produce_sync_committee_signature( - &self, - slot: Slot, - beacon_block_root: Hash256, - validator_index: u64, - validator_pubkey: &PublicKeyBytes, - ) -> Result<SyncCommitteeMessage, Error> { - let signing_epoch = slot.epoch(E::slots_per_epoch()); - let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch); - - // Bypass `with_validator_signing_method`: sync committee messages are not slashable. - let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?; - - let signature = signing_method - .get_signature::<E, BlindedPayload<E>>( - SignableMessage::SyncCommitteeSignature { - beacon_block_root, - slot, + fn sign_aggregate_and_proofs( + self: &Arc<Self>, + aggregates: Vec<AggregateToSign<E>>, + ) -> impl Stream<Item = Result<Vec<SignedAggregateAndProof<E>>, Error>> + Send { + let store = self.clone(); + let count = aggregates.len(); + stream::once(async move { + let signing_futures = aggregates.into_iter().map( + |AggregateToSign { + pubkey, + aggregator_index, + aggregate, + selection_proof, + }| { + let store = store.clone(); + async move { + let result = store + .produce_signed_aggregate_and_proof( + pubkey, + aggregator_index, + aggregate, + selection_proof, + ) + .await; + (pubkey, result) + } }, - signing_context, - &self.spec, - &self.task_executor, - ) - .await - .map_err(Error::SpecificError)?; + ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL, - &[validator_metrics::SUCCESS], - ); + let results = join_all(signing_futures) + .instrument(info_span!("sign_aggregates", count)) + .await; - Ok(SyncCommitteeMessage { - slot, - beacon_block_root, - validator_index, - signature, + let mut signed = Vec::with_capacity(results.len()); + for (pubkey, result) in results { + match result { + Ok(agg) => signed.push(agg), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!(?pubkey, "Missing pubkey for aggregate"); + } + Err(e) => { + crit!(error = ?e, pubkey = ?pubkey, "Failed to sign aggregate"); + } + } + } + Ok(signed) }) } - async fn produce_signed_contribution_and_proof( - &self, - aggregator_index: u64, - aggregator_pubkey: PublicKeyBytes, - contribution: SyncCommitteeContribution<E>, - selection_proof: SyncSelectionProof, - ) -> Result<SignedContributionAndProof<E>, Error> { - let signing_epoch = contribution.slot.epoch(E::slots_per_epoch()); - let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch); + fn sign_sync_committee_signatures( + self: &Arc<Self>, + messages: Vec<SyncMessageToSign>, + ) -> impl Stream<Item = Result<Vec<SyncCommitteeMessage>, Error>> + Send { + let store = self.clone(); + let count = messages.len(); + stream::once(async move { + let signing_futures = messages.into_iter().map( + |SyncMessageToSign { + slot, + beacon_block_root, + validator_index, + pubkey, + }| { + let store = store.clone(); + async move { + let result = store + .produce_sync_committee_signature( + slot, + beacon_block_root, + validator_index, + &pubkey, + ) + .await; + (pubkey, validator_index, slot, result) + } + }, + ); - // Bypass `with_validator_signing_method`: sync committee messages are not slashable. - let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?; + let results = join_all(signing_futures) + .instrument(info_span!("sign_sync_signatures", count)) + .await; - let message = ContributionAndProof { - aggregator_index, - contribution, - selection_proof: selection_proof.into(), - }; + let mut signed = Vec::with_capacity(results.len()); + for (_pubkey, validator_index, slot, result) in results { + match result { + Ok(sig) => signed.push(sig), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!( + ?pubkey, + validator_index, + %slot, + "Missing pubkey for sync committee signature" + ); + } + Err(e) => { + crit!( + validator_index, + %slot, + error = ?e, + "Failed to sign sync committee signature" + ); + } + } + } + Ok(signed) + }) + } - let signature = signing_method - .get_signature::<E, BlindedPayload<E>>( - SignableMessage::SignedContributionAndProof(&message), - signing_context, - &self.spec, - &self.task_executor, - ) - .await - .map_err(Error::SpecificError)?; + fn sign_sync_committee_contributions( + self: &Arc<Self>, + contributions: Vec<ContributionToSign<E>>, + ) -> impl Stream<Item = Result<Vec<SignedContributionAndProof<E>>, Error>> + Send { + let store = self.clone(); + let count = contributions.len(); + stream::once(async move { + let signing_futures = contributions.into_iter().map( + |ContributionToSign { + aggregator_index, + aggregator_pubkey, + contribution, + selection_proof, + }| { + let store = store.clone(); + let slot = contribution.slot; + async move { + let result = store + .produce_signed_contribution_and_proof( + aggregator_index, + aggregator_pubkey, + contribution, + selection_proof, + ) + .await; + (slot, result) + } + }, + ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL, - &[validator_metrics::SUCCESS], - ); + let results = join_all(signing_futures) + .instrument(info_span!("sign_sync_contributions", count)) + .await; - Ok(SignedContributionAndProof { message, signature }) + let mut signed = Vec::with_capacity(results.len()); + for (slot, result) in results { + match result { + Ok(contribution) => signed.push(contribution), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!(?pubkey, %slot, "Missing pubkey for sync contribution"); + } + Err(e) => { + crit!( + %slot, + error = ?e, + "Unable to sign sync committee contribution" + ); + } + } + } + Ok(signed) + }) } /// Prune the slashing protection database so that it remains performant. @@ -1155,4 +1447,66 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_payload_attestation( + &self, + validator_pubkey: PublicKeyBytes, + data: PayloadAttestationData, + ) -> Result<PayloadAttestationMessage, Error> { + let signing_context = + self.signing_context(Domain::PTCAttester, data.slot.epoch(E::slots_per_epoch())); + + let validator_index = self + .validator_index(&validator_pubkey) + .ok_or(ValidatorStoreError::UnknownPubkey(validator_pubkey))?; + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::<E, FullPayload<E>>( + SignableMessage::PayloadAttestationData(&data), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(PayloadAttestationMessage { + validator_index, + data, + signature, + }) + } + + /// Sign an `ExecutionPayloadEnvelope` for Gloas (local building). + /// The proposer acts as the builder and signs with the BeaconBuilder domain. + async fn sign_execution_payload_envelope( + &self, + validator_pubkey: PublicKeyBytes, + envelope: ExecutionPayloadEnvelope<E>, + ) -> Result<SignedExecutionPayloadEnvelope<E>, Error> { + let signing_context = self.signing_context( + Domain::BeaconBuilder, + envelope.slot().epoch(E::slots_per_epoch()), + ); + + // Execution payload envelope signing is not slashable, bypass doppelganger protection. + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::<E, FullPayload<E>>( + SignableMessage::ExecutionPayloadEnvelope(&envelope), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(SignedExecutionPayloadEnvelope { + message: envelope, + signature, + }) + } } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 56d9784d8e..5de14e9549 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -10,7 +10,7 @@ use parking_lot::Mutex; use reqwest::{Client, header::ACCEPT}; use std::path::PathBuf; use std::sync::Arc; -use task_executor::TaskExecutor; +use task_executor::{RayonPoolType, TaskExecutor}; use tracing::instrument; use types::*; use url::Url; @@ -50,6 +50,8 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload<E> = FullP ValidatorRegistration(&'a ValidatorRegistrationData), VoluntaryExit(&'a VoluntaryExit), InclusionList(&'a InclusionList<E>), + ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope<E>), + PayloadAttestationData(&'a PayloadAttestationData), } impl<E: EthSpec, Payload: AbstractExecPayload<E>> SignableMessage<'_, E, Payload> { @@ -72,6 +74,8 @@ impl<E: EthSpec, Payload: AbstractExecPayload<E>> SignableMessage<'_, E, Payload SignableMessage::ValidatorRegistration(v) => v.signing_root(domain), SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain), SignableMessage::InclusionList(il) => il.signing_root(domain), + SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), + SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), } } } @@ -183,14 +187,16 @@ impl SigningMethod { let voting_keypair = voting_keypair.clone(); // Spawn a blocking task to produce the signature. This avoids blocking the core // tokio executor. + // + // We are using the Rayon high-priority pool which uses up to 80% of available + // threads. In future we could consider using 90-100% in the VC, seeing as we have + // very little other work to do aside from signing. let signature = executor - .spawn_blocking_handle( - move || voting_keypair.sk.sign(signing_root), - "local_keystore_signer", - ) - .ok_or(Error::ShuttingDown)? + .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { + voting_keypair.sk.sign(signing_root) + }) .await - .map_err(|e| Error::TokioJoin(e.to_string()))?; + .map_err(|_| Error::ShuttingDown)?; Ok(signature) } SigningMethod::Web3Signer { @@ -234,6 +240,12 @@ impl SigningMethod { } SignableMessage::VoluntaryExit(e) => Web3SignerObject::VoluntaryExit(e), SignableMessage::InclusionList(il) => Web3SignerObject::InclusionList(il), + SignableMessage::ExecutionPayloadEnvelope(e) => { + Web3SignerObject::ExecutionPayloadEnvelope(e) + } + SignableMessage::PayloadAttestationData(d) => { + Web3SignerObject::PayloadAttestationData(d) + } }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 4cab2b08b1..800b4ae556 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -20,6 +20,9 @@ pub enum MessageType { SyncCommitteeContributionAndProof, ValidatorRegistration, InclusionList, + // TODO(gloas) verify w/ web3signer specs + ExecutionPayloadEnvelope, + PayloadAttestation, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -78,6 +81,8 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload<E>> { ContributionAndProof(&'a ContributionAndProof<E>), ValidatorRegistration(&'a ValidatorRegistrationData), InclusionList(&'a InclusionList<E>), + ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope<E>), + PayloadAttestationData(&'a PayloadAttestationData), } impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> Web3SignerObject<'a, E, Payload> { @@ -149,6 +154,8 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload<E>> Web3SignerObject<'a, E, Pa } Web3SignerObject::ValidatorRegistration(_) => MessageType::ValidatorRegistration, Web3SignerObject::InclusionList(_) => MessageType::InclusionList, + Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, + Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, } } } diff --git a/validator_client/slashing_protection/Cargo.toml b/validator_client/slashing_protection/Cargo.toml index b80da6c786..8017941ca6 100644 --- a/validator_client/slashing_protection/Cargo.toml +++ b/validator_client/slashing_protection/Cargo.toml @@ -6,24 +6,24 @@ edition = { workspace = true } autotests = false [features] -arbitrary-fuzz = ["types/arbitrary-fuzz", "eip_3076/arbitrary-fuzz"] +arbitrary = ["dep:arbitrary", "types/arbitrary", "eip_3076/arbitrary"] portable = ["types/portable"] [dependencies] -arbitrary = { workspace = true, features = ["derive"] } +arbitrary = { workspace = true, features = ["derive"], optional = true } bls = { workspace = true } eip_3076 = { workspace = true, features = ["json"] } ethereum_serde_utils = { workspace = true } filesystem = { workspace = true } fixed_bytes = { workspace = true } r2d2 = { workspace = true } -r2d2_sqlite = "0.21.0" +r2d2_sqlite = "0.32" rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } tracing = { workspace = true } -types = { workspace = true } +types = { workspace = true, features = ["sqlite"] } [dev-dependencies] rayon = { workspace = true } diff --git a/validator_client/slashing_protection/src/interchange_test.rs b/validator_client/slashing_protection/src/interchange_test.rs index 0dfcda204d..996116dd1c 100644 --- a/validator_client/slashing_protection/src/interchange_test.rs +++ b/validator_client/slashing_protection/src/interchange_test.rs @@ -11,7 +11,7 @@ use tempfile::tempdir; use types::{Epoch, Hash256, Slot}; #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MultiTestCase { pub name: String, pub genesis_validators_root: Hash256, @@ -19,7 +19,7 @@ pub struct MultiTestCase { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TestCase { pub should_succeed: bool, pub contains_slashable_data: bool, @@ -29,7 +29,7 @@ pub struct TestCase { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TestBlock { pub pubkey: PublicKeyBytes, pub slot: Slot, @@ -39,7 +39,7 @@ pub struct TestBlock { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TestAttestation { pub pubkey: PublicKeyBytes, pub source_epoch: Epoch, @@ -135,12 +135,15 @@ impl MultiTestCase { } for (i, att) in test_case.attestations.iter().enumerate() { - match slashing_db.check_and_insert_attestation_signing_root( - &att.pubkey, - att.source_epoch, - att.target_epoch, - SigningRoot::from(att.signing_root), - ) { + match slashing_db.with_transaction(|txn| { + slashing_db.check_and_insert_attestation_signing_root( + &att.pubkey, + att.source_epoch, + att.target_epoch, + SigningRoot::from(att.signing_root), + txn, + ) + }) { Ok(safe) if !att.should_succeed => { panic!( "attestation {} from `{}` succeeded when it should have failed: {:?}", diff --git a/validator_client/slashing_protection/src/lib.rs b/validator_client/slashing_protection/src/lib.rs index f8580e7315..d8039acda6 100644 --- a/validator_client/slashing_protection/src/lib.rs +++ b/validator_client/slashing_protection/src/lib.rs @@ -16,8 +16,8 @@ pub mod interchange { pub use crate::signed_attestation::{InvalidAttestation, SignedAttestation}; pub use crate::signed_block::{InvalidBlock, SignedBlock}; pub use crate::slashing_database::{ - InterchangeError, InterchangeImportOutcome, SUPPORTED_INTERCHANGE_FORMAT_VERSION, - SlashingDatabase, + CheckSlashability, InterchangeError, InterchangeImportOutcome, + SUPPORTED_INTERCHANGE_FORMAT_VERSION, SlashingDatabase, }; use bls::PublicKeyBytes; use rusqlite::Error as SQLError; diff --git a/validator_client/slashing_protection/src/parallel_tests.rs b/validator_client/slashing_protection/src/parallel_tests.rs index e3cc1a0d56..57709e0bf5 100644 --- a/validator_client/slashing_protection/src/parallel_tests.rs +++ b/validator_client/slashing_protection/src/parallel_tests.rs @@ -44,11 +44,14 @@ fn attestation_same_target() { let results = (0..num_attestations) .into_par_iter() .map(|i| { - slashing_db.check_and_insert_attestation( - &pk, - &attestation_data_builder(i, num_attestations), - DEFAULT_DOMAIN, - ) + slashing_db.with_transaction(|txn| { + slashing_db.check_and_insert_attestation( + &pk, + &attestation_data_builder(i, num_attestations), + DEFAULT_DOMAIN, + txn, + ) + }) }) .collect::<Vec<_>>(); @@ -73,7 +76,9 @@ fn attestation_surround_fest() { .into_par_iter() .map(|i| { let att = attestation_data_builder(i, 2 * num_attestations - i); - slashing_db.check_and_insert_attestation(&pk, &att, DEFAULT_DOMAIN) + slashing_db.with_transaction(|txn| { + slashing_db.check_and_insert_attestation(&pk, &att, DEFAULT_DOMAIN, txn) + }) }) .collect::<Vec<_>>(); diff --git a/validator_client/slashing_protection/src/slashing_database.rs b/validator_client/slashing_protection/src/slashing_database.rs index 67e1234ac5..bb11e50ffb 100644 --- a/validator_client/slashing_protection/src/slashing_database.rs +++ b/validator_client/slashing_protection/src/slashing_database.rs @@ -38,6 +38,17 @@ pub struct SlashingDatabase { conn_pool: Pool, } +/// Whether to check slashability of a message. +/// +/// The `No` variant MUST only be used if there is another source of slashing protection configured, +/// e.g. web3signer's slashing protection. +#[derive(Debug, Clone, Copy, Default)] +pub enum CheckSlashability { + #[default] + Yes, + No, +} + impl SlashingDatabase { /// Open an existing database at the given `path`, or create one if none exists. pub fn open_or_create(path: &Path) -> Result<Self, NotSafe> { @@ -183,7 +194,9 @@ impl SlashingDatabase { U: From<NotSafe>, { let mut conn = self.conn_pool.get().map_err(NotSafe::from)?; - let txn = conn.transaction().map_err(NotSafe::from)?; + let txn = conn + .transaction_with_behavior(TransactionBehavior::Exclusive) + .map_err(NotSafe::from)?; let value = f(&txn)?; txn.commit().map_err(NotSafe::from)?; Ok(value) @@ -635,6 +648,43 @@ impl SlashingDatabase { self.check_block_proposal(&txn, validator_pubkey, slot, signing_root) } + #[instrument(name = "db_check_and_insert_attestations", level = "debug", skip_all)] + pub fn check_and_insert_attestations<'a>( + &self, + attestations: &'a [( + &'a AttestationData, + &'a PublicKeyBytes, + Hash256, + CheckSlashability, + )], + ) -> Result<Vec<Result<Safe, NotSafe>>, NotSafe> { + let mut conn = self.conn_pool.get()?; + let txn = conn.transaction_with_behavior(TransactionBehavior::Exclusive)?; + + let mut results = Vec::with_capacity(attestations.len()); + for (attestation, validator_pubkey, domain, check_slashability) in attestations { + match check_slashability { + CheckSlashability::No => { + results.push(Ok(Safe::Valid)); + } + CheckSlashability::Yes => { + let attestation_signing_root = attestation.signing_root(*domain).into(); + results.push(self.check_and_insert_attestation_signing_root( + validator_pubkey, + attestation.source.epoch, + attestation.target.epoch, + attestation_signing_root, + &txn, + )); + } + } + } + + txn.commit()?; + + Ok(results) + } + /// Check an attestation for slash safety, and if it is safe, record it in the database. /// /// The checking and inserting happen atomically and exclusively. We enforce exclusivity @@ -647,6 +697,7 @@ impl SlashingDatabase { validator_pubkey: &PublicKeyBytes, attestation: &AttestationData, domain: Hash256, + txn: &Transaction, ) -> Result<Safe, NotSafe> { let attestation_signing_root = attestation.signing_root(domain).into(); self.check_and_insert_attestation_signing_root( @@ -654,6 +705,7 @@ impl SlashingDatabase { attestation.source.epoch, attestation.target.epoch, attestation_signing_root, + txn, ) } @@ -664,17 +716,15 @@ impl SlashingDatabase { att_source_epoch: Epoch, att_target_epoch: Epoch, att_signing_root: SigningRoot, + txn: &Transaction, ) -> Result<Safe, NotSafe> { - let mut conn = self.conn_pool.get()?; - let txn = conn.transaction_with_behavior(TransactionBehavior::Exclusive)?; let safe = self.check_and_insert_attestation_signing_root_txn( validator_pubkey, att_source_epoch, att_target_epoch, att_signing_root, - &txn, + txn, )?; - txn.commit()?; Ok(safe) } diff --git a/validator_client/slashing_protection/src/test_utils.rs b/validator_client/slashing_protection/src/test_utils.rs index 39ede58bb2..28370ba3e8 100644 --- a/validator_client/slashing_protection/src/test_utils.rs +++ b/validator_client/slashing_protection/src/test_utils.rs @@ -1,3 +1,4 @@ +use crate::slashing_database::CheckSlashability; use crate::*; use tempfile::{TempDir, tempdir}; use types::{AttestationData, BeaconBlockHeader, test_utils::generate_deterministic_keypair}; @@ -72,6 +73,12 @@ impl<T> Default for StreamTest<T> { impl StreamTest<AttestationData> { pub fn run(&self) { + self.run_solo(); + self.run_batched(); + } + + // Run the test with every attestation processed individually. + pub fn run_solo(&self) { let dir = tempdir().unwrap(); let slashing_db_file = dir.path().join("slashing_protection.sqlite"); let slashing_db = SlashingDatabase::create(&slashing_db_file).unwrap(); @@ -84,7 +91,12 @@ impl StreamTest<AttestationData> { for (i, test) in self.cases.iter().enumerate() { assert_eq!( - slashing_db.check_and_insert_attestation(&test.pubkey, &test.data, test.domain), + slashing_db.with_transaction(|txn| slashing_db.check_and_insert_attestation( + &test.pubkey, + &test.data, + test.domain, + txn + )), test.expected, "attestation {} not processed as expected", i @@ -93,6 +105,48 @@ impl StreamTest<AttestationData> { roundtrip_database(&dir, &slashing_db, self.registered_validators.is_empty()); } + + // Run the test with all attestations processed by the slashing DB as part of a batch. + pub fn run_batched(&self) { + let dir = tempdir().unwrap(); + let slashing_db_file = dir.path().join("slashing_protection.sqlite"); + let slashing_db = SlashingDatabase::create(&slashing_db_file).unwrap(); + + for pubkey in &self.registered_validators { + slashing_db.register_validator(*pubkey).unwrap(); + } + + check_registration_invariants(&slashing_db, &self.registered_validators); + + let attestations_to_check = self + .cases + .iter() + .map(|test| { + ( + &test.data, + &test.pubkey, + test.domain, + CheckSlashability::Yes, + ) + }) + .collect::<Vec<_>>(); + + let results = slashing_db + .check_and_insert_attestations(&attestations_to_check) + .unwrap(); + + assert_eq!(results.len(), self.cases.len()); + + for ((i, test), result) in self.cases.iter().enumerate().zip(results) { + assert_eq!( + result, test.expected, + "attestation {} not processed as expected", + i + ); + } + + roundtrip_database(&dir, &slashing_db, self.registered_validators.is_empty()); + } } impl StreamTest<BeaconBlockHeader> { diff --git a/validator_client/src/cli.rs b/validator_client/src/cli.rs index 3e1c46097f..0eb0e9e5dd 100644 --- a/validator_client/src/cli.rs +++ b/validator_client/src/cli.rs @@ -476,6 +476,17 @@ pub struct ValidatorClient { )] pub beacon_nodes_sync_tolerances: Vec<u64>, + #[clap( + long, + help = "Disable the beacon head monitor which tries to attest as soon as any of the \ + configured beacon nodes sends a head event. Leaving the service enabled is \ + recommended, but disabling it can lead to reduced bandwidth and more predictable \ + usage of the primary beacon node (rather than the fastest BN).", + display_order = 0, + help_heading = FLAG_HEADER + )] + pub disable_beacon_head_monitor: bool, + #[clap( long, help = "Disable Lighthouse's slashing protection for all web3signer keys. This can \ diff --git a/validator_client/src/config.rs b/validator_client/src/config.rs index 1a286a74dc..d68a78b705 100644 --- a/validator_client/src/config.rs +++ b/validator_client/src/config.rs @@ -82,6 +82,8 @@ pub struct Config { pub broadcast_topics: Vec<ApiTopic>, /// Enables a service which attempts to measure latency between the VC and BNs. pub enable_latency_measurement_service: bool, + /// Enables the beacon head monitor that reacts to head updates from connected beacon nodes. + pub enable_beacon_head_monitor: bool, /// Defines the number of validators per `validator/register_validator` request sent to the BN. pub validator_registration_batch_size: usize, /// Whether we are running with distributed network support. @@ -132,6 +134,7 @@ impl Default for Config { builder_registration_timestamp_override: None, broadcast_topics: vec![ApiTopic::Subscriptions], enable_latency_measurement_service: true, + enable_beacon_head_monitor: true, validator_registration_batch_size: 500, distributed: false, initialized_validators: <_>::default(), @@ -377,6 +380,7 @@ impl Config { config.validator_store.builder_boost_factor = validator_client_config.builder_boost_factor; config.enable_latency_measurement_service = !validator_client_config.disable_latency_measurement_service; + config.enable_beacon_head_monitor = !validator_client_config.disable_beacon_head_monitor; config.validator_registration_batch_size = validator_client_config.validator_registration_batch_size; diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 12323710f0..6f0163a7b6 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -9,19 +9,21 @@ use metrics::set_gauge; use monitoring_api::{MonitoringHttpClient, ProcessType}; use sensitive_url::SensitiveUrl; use slashing_protection::{SLASHING_PROTECTION_FILENAME, SlashingDatabase}; +use tokio::sync::Mutex; use account_utils::validator_definitions::ValidatorDefinitions; use beacon_node_fallback::{ - BeaconNodeFallback, CandidateBeaconNode, start_fallback_updater_service, + BeaconNodeFallback, CandidateBeaconNode, beacon_head_monitor::HeadEvent, + start_fallback_updater_service, }; use clap::ArgMatches; use doppelganger_service::DoppelgangerService; use environment::RuntimeContext; -use eth2::{BeaconNodeHttpClient, StatusCode, Timeouts, reqwest::ClientBuilder}; +use eth2::{BeaconNodeHttpClient, Timeouts}; use initialized_validators::Error::UnableToOpenVotingKeystore; use lighthouse_validator_store::LighthouseValidatorStore; use parking_lot::RwLock; -use reqwest::Certificate; +use reqwest::{Certificate, ClientBuilder, StatusCode}; use slot_clock::SlotClock; use slot_clock::SystemTimeSlotClock; use std::fs::File; @@ -45,6 +47,7 @@ use validator_services::{ duties_service::{self, DutiesService, DutiesServiceBuilder}, inclusion_list_service::InclusionListService, latency_service, + payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, sync_committee_service::SyncCommitteeService, }; @@ -72,6 +75,8 @@ pub const AGGREGATION_PRE_COMPUTE_EPOCHS: u64 = 2; /// Number of slots in advance to compute sync selection proofs when in `distributed` mode. pub const AGGREGATION_PRE_COMPUTE_SLOTS_DISTRIBUTED: u64 = 1; +const MAX_HEAD_EVENT_QUEUE_LEN: usize = 1_024; + type ValidatorStore<E> = LighthouseValidatorStore<SystemTimeSlotClock, E>; #[derive(Clone)] @@ -82,6 +87,7 @@ pub struct ProductionValidatorClient<E: EthSpec> { attestation_service: AttestationService<ValidatorStore<E>, SystemTimeSlotClock>, sync_committee_service: SyncCommitteeService<ValidatorStore<E>, SystemTimeSlotClock>, inclusion_list_service: InclusionListService<ValidatorStore<E>, SystemTimeSlotClock>, + payload_attestation_service: PayloadAttestationService<ValidatorStore<E>, SystemTimeSlotClock>, doppelganger_service: Option<Arc<DoppelgangerService>>, preparation_service: PreparationService<ValidatorStore<E>, SystemTimeSlotClock>, validator_store: Arc<ValidatorStore<E>>, @@ -186,6 +192,9 @@ impl<E: EthSpec> ProductionValidatorClient<E> { info!(new_validators, "Completed validator discovery"); } + // Check for all validators' fee recipient + validator_defs.check_all_fee_recipients(config.validator_store.fee_recipient)?; + let validators = InitializedValidators::from_definitions( validator_defs, config.validator_dir.clone(), @@ -271,7 +280,7 @@ impl<E: EthSpec> ProductionValidatorClient<E> { let beacon_node_setup = |x: (usize, &SensitiveUrl)| { let i = x.0; let url = x.1; - let slot_duration = Duration::from_secs(context.eth2_config.spec.seconds_per_slot); + let slot_duration = context.eth2_config.spec.get_slot_duration(); let mut beacon_node_http_client_builder = ClientBuilder::new(); @@ -367,11 +376,22 @@ impl<E: EthSpec> ProductionValidatorClient<E> { context.eth2_config.spec.clone(), ); - // Perform some potentially long-running initialization tasks. - let (genesis_time, genesis_validators_root) = tokio::select! { - tuple = init_from_beacon_node::<E>(&beacon_nodes, &proposer_nodes) => tuple?, - () = context.executor.exit() => return Err("Shutting down".to_string()) - }; + let (genesis_time, genesis_validators_root) = + if let Some(eth2_network_config) = context.eth2_network_config.as_ref() { + let time = eth2_network_config + .genesis_time::<E>()? + .ok_or("no genesis time")?; + let root = eth2_network_config + .genesis_validators_root::<E>()? + .ok_or("no genesis validators root")?; + (time, root) + } else { + // Perform some potentially long-running initialization tasks. + tokio::select! { + tuple = init_from_beacon_node::<E>(&beacon_nodes, &proposer_nodes) => tuple?, + () = context.executor.exit() => return Err("Shutting down".to_string()), + } + }; // Update the metrics server. if let Some(ctx) = &validator_metrics_ctx { @@ -381,12 +401,23 @@ impl<E: EthSpec> ProductionValidatorClient<E> { let slot_clock = SystemTimeSlotClock::new( context.eth2_config.spec.genesis_slot, Duration::from_secs(genesis_time), - Duration::from_secs(context.eth2_config.spec.seconds_per_slot), + context.eth2_config.spec.get_slot_duration(), ); beacon_nodes.set_slot_clock(slot_clock.clone()); proposer_nodes.set_slot_clock(slot_clock.clone()); + // Only the beacon_nodes are used for attestation duties and thus biconditionally + // proposer_nodes do not need head_send ref. + let head_monitor_rx = if config.enable_beacon_head_monitor { + let (head_monitor_tx, head_receiver) = + mpsc::channel::<HeadEvent>(MAX_HEAD_EVENT_QUEUE_LEN); + beacon_nodes.set_head_send(Arc::new(head_monitor_tx)); + Some(Mutex::new(head_receiver)) + } else { + None + }; + let beacon_nodes = Arc::new(beacon_nodes); start_fallback_updater_service::<_, E>(context.executor.clone(), beacon_nodes.clone())?; @@ -497,15 +528,17 @@ impl<E: EthSpec> ProductionValidatorClient<E> { let block_service = block_service_builder.build()?; - let attestation_service = AttestationServiceBuilder::new() + let attestation_builder = AttestationServiceBuilder::new() .duties_service(duties_service.clone()) .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) .beacon_nodes(beacon_nodes.clone()) .executor(context.executor.clone()) + .head_monitor_rx(head_monitor_rx) .chain_spec(context.eth2_config.spec.clone()) - .disable(config.disable_attesting) - .build()?; + .disable(config.disable_attesting); + + let attestation_service = attestation_builder.build()?; let preparation_service = PreparationServiceBuilder::new() .slot_clock(slot_clock.clone()) @@ -535,6 +568,15 @@ impl<E: EthSpec> ProductionValidatorClient<E> { .disable(false) .build()?; + let payload_attestation_service = PayloadAttestationService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -542,6 +584,7 @@ impl<E: EthSpec> ProductionValidatorClient<E> { attestation_service, sync_committee_service, inclusion_list_service, + payload_attestation_service, doppelganger_service, preparation_service, validator_store, @@ -618,6 +661,13 @@ impl<E: EthSpec> ProductionValidatorClient<E> { .start_update_service(&self.context.eth2_config.spec) .map_err(|e| format!("Unable to start inclusion list service: {}", e))?; + if self.context.eth2_config.spec.is_gloas_scheduled() { + self.payload_attestation_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start payload attestation service: {}", e))?; + } + self.preparation_service .clone() .start_update_service(&self.context.eth2_config.spec) diff --git a/validator_client/validator_metrics/src/lib.rs b/validator_client/validator_metrics/src/lib.rs index 060d8a4edd..46a86381f9 100644 --- a/validator_client/validator_metrics/src/lib.rs +++ b/validator_client/validator_metrics/src/lib.rs @@ -22,7 +22,12 @@ pub const UPDATE_ATTESTERS_CURRENT_EPOCH: &str = "update_attesters_current_epoch pub const UPDATE_ATTESTERS_NEXT_EPOCH: &str = "update_attesters_next_epoch"; pub const UPDATE_ATTESTERS_FETCH: &str = "update_attesters_fetch"; pub const UPDATE_ATTESTERS_STORE: &str = "update_attesters_store"; +pub const UPDATE_PTC_CURRENT_EPOCH: &str = "update_ptc_current_epoch"; +pub const UPDATE_PTC_NEXT_EPOCH: &str = "update_ptc_next_epoch"; +pub const UPDATE_PTC_FETCH: &str = "update_ptc_fetch"; +pub const UPDATE_PTC_STORE: &str = "update_ptc_store"; pub const ATTESTER_DUTIES_HTTP_POST: &str = "attester_duties_http_post"; +pub const PTC_DUTIES_HTTP_POST: &str = "ptc_duties_http_post"; pub const PROPOSER_DUTIES_HTTP_GET: &str = "proposer_duties_http_get"; pub const VALIDATOR_DUTIES_SYNC_HTTP_POST: &str = "validator_duties_sync_http_post"; pub const VALIDATOR_ID_HTTP_GET: &str = "validator_id_http_get"; @@ -162,6 +167,13 @@ pub static ATTESTER_COUNT: LazyLock<Result<IntGaugeVec>> = LazyLock::new(|| { &["task"], ) }); +pub static PTC_COUNT: LazyLock<Result<IntGaugeVec>> = LazyLock::new(|| { + try_create_int_gauge_vec( + "vc_beacon_ptc_count", + "Number of PTC (Payload Timeliness Committee) validators on this host", + &["task"], + ) +}); pub static PROPOSAL_CHANGED: LazyLock<Result<IntCounter>> = LazyLock::new(|| { try_create_int_counter( "vc_beacon_block_proposal_changed", diff --git a/validator_client/validator_services/Cargo.toml b/validator_client/validator_services/Cargo.toml index c914940914..2582968265 100644 --- a/validator_client/validator_services/Cargo.toml +++ b/validator_client/validator_services/Cargo.toml @@ -13,6 +13,7 @@ futures = { workspace = true } graffiti_file = { workspace = true } logging = { workspace = true } parking_lot = { workspace = true } +reqwest = { workspace = true } safe_arith = { workspace = true } slot_clock = { workspace = true } task_executor = { workspace = true } diff --git a/validator_client/validator_services/src/attestation_service.rs b/validator_client/validator_services/src/attestation_service.rs index 587d4668b8..3ffe602892 100644 --- a/validator_client/validator_services/src/attestation_service.rs +++ b/validator_client/validator_services/src/attestation_service.rs @@ -1,17 +1,19 @@ use crate::duties_service::{DutiesService, DutyAndProof}; -use beacon_node_fallback::{ApiTopic, BeaconNodeFallback}; -use futures::future::join_all; +use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, beacon_head_monitor::HeadEvent}; +use futures::StreamExt; use logging::crit; use slot_clock::SlotClock; use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; use task_executor::TaskExecutor; +use tokio::sync::Mutex; +use tokio::sync::mpsc; use tokio::time::{Duration, Instant, sleep, sleep_until}; -use tracing::{Instrument, Span, debug, error, info, info_span, instrument, trace, warn}; +use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use tree_hash::TreeHash; -use types::{Attestation, AttestationData, ChainSpec, CommitteeIndex, EthSpec, Slot}; -use validator_store::{Error as ValidatorStoreError, ValidatorStore}; +use types::{Attestation, AttestationData, ChainSpec, CommitteeIndex, EthSpec, Hash256, Slot}; +use validator_store::{AggregateToSign, AttestationToSign, ValidatorStore}; /// Builds an `AttestationService`. #[derive(Default)] @@ -22,6 +24,7 @@ pub struct AttestationServiceBuilder<S: ValidatorStore, T: SlotClock + 'static> beacon_nodes: Option<Arc<BeaconNodeFallback<T>>>, executor: Option<TaskExecutor>, chain_spec: Option<Arc<ChainSpec>>, + head_monitor_rx: Option<Mutex<mpsc::Receiver<HeadEvent>>>, disable: bool, } @@ -34,6 +37,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationServiceBuil beacon_nodes: None, executor: None, chain_spec: None, + head_monitor_rx: None, disable: false, } } @@ -73,6 +77,13 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationServiceBuil self } + pub fn head_monitor_rx( + mut self, + head_monitor_rx: Option<Mutex<mpsc::Receiver<HeadEvent>>>, + ) -> Self { + self.head_monitor_rx = head_monitor_rx; + self + } pub fn build(self) -> Result<AttestationService<S, T>, String> { Ok(AttestationService { inner: Arc::new(Inner { @@ -94,7 +105,9 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationServiceBuil chain_spec: self .chain_spec .ok_or("Cannot build AttestationService without chain_spec")?, + head_monitor_rx: self.head_monitor_rx, disable: self.disable, + latest_attested_slot: Mutex::new(Slot::default()), }), }) } @@ -108,10 +121,13 @@ pub struct Inner<S, T> { beacon_nodes: Arc<BeaconNodeFallback<T>>, executor: TaskExecutor, chain_spec: Arc<ChainSpec>, + head_monitor_rx: Option<Mutex<mpsc::Receiver<HeadEvent>>>, disable: bool, + latest_attested_slot: Mutex<Slot>, } -/// Attempts to produce attestations for all known validators 1/3rd of the way through each slot. +/// Attempts to produce attestations for all known validators 1/3rd of the way through each slot +/// or when a head event is received from the BNs. /// /// If any validators are on the same committee, a single attestation will be downloaded and /// returned to the beacon node. This attestation will have a signature from each of the @@ -144,7 +160,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, return Ok(()); } - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let duration_to_next_slot = self .slot_clock .duration_to_next_slot() @@ -157,21 +173,46 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, let executor = self.executor.clone(); + let unaggregated_attestation_due = self.chain_spec.get_unaggregated_attestation_due(); + let interval_fut = async move { loop { - if let Some(duration_to_next_slot) = self.slot_clock.duration_to_next_slot() { - sleep(duration_to_next_slot + slot_duration / 3).await; - - if let Err(e) = self.spawn_attestation_tasks(slot_duration) { - crit!(error = e, "Failed to spawn attestation tasks") - } else { - trace!("Spawned attestation tasks"); - } - } else { + let Some(duration) = self.slot_clock.duration_to_next_slot() else { error!("Failed to read slot clock"); - // If we can't read the slot clock, just wait another slot. sleep(slot_duration).await; continue; + }; + + let beacon_node_data = if self.head_monitor_rx.is_some() { + tokio::select! { + _ = sleep(duration + unaggregated_attestation_due) => None, + event = self.poll_for_head_events() => + event.map(|event| (event.beacon_node_index, event.beacon_block_root)), + } + } else { + sleep(duration + unaggregated_attestation_due).await; + None + }; + + let Some(current_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after trigger"); + continue; + }; + + let mut last_slot = self.latest_attested_slot.lock().await; + + if current_slot <= *last_slot { + debug!(%current_slot, "Attestation already initiated for the slot"); + continue; + } + + match self.spawn_attestation_tasks(beacon_node_data).await { + Ok(_) => { + *last_slot = current_slot; + } + Err(e) => { + crit!(error = e, "Failed to spawn attestation tasks") + } } } }; @@ -180,15 +221,38 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, Ok(()) } + async fn poll_for_head_events(&self) -> Option<HeadEvent> { + let Some(receiver) = &self.head_monitor_rx else { + return None; + }; + let mut receiver = receiver.lock().await; + loop { + match receiver.recv().await { + Some(head_event) => { + // Only return head events for the current slot - this ensures the + // block for this slot has been produced before triggering attestation + let current_slot = self.slot_clock.now()?; + if head_event.slot == current_slot { + return Some(head_event); + } + // Head event is for a previous slot, keep waiting + } + None => { + warn!("Head monitor channel closed unexpectedly"); + return None; + } + } + } + } + /// Spawn only one new task for attestation post-Electra /// For each required aggregates, spawn a new task that downloads, signs and uploads the /// aggregates to the beacon node. - fn spawn_attestation_tasks(&self, slot_duration: Duration) -> Result<(), String> { + async fn spawn_attestation_tasks( + &self, + beacon_node_data: Option<(usize, Hash256)>, + ) -> Result<(), String> { let slot = self.slot_clock.now().ok_or("Failed to read slot clock")?; - let duration_to_next_slot = self - .slot_clock - .duration_to_next_slot() - .ok_or("Unable to determine duration to next slot")?; // Create and publish an `Attestation` for all validators only once // as the committee_index is not included in AttestationData post-Electra @@ -199,29 +263,89 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, return Ok(()); } + debug!( + %slot, + from_head_monitor = beacon_node_data.is_some(), + "Starting attestation production" + ); + let attestation_service = self.clone(); - let attestation_data_handle = self + let mut attestation_data_from_head_event = None; + + if let Some((beacon_node_index, expected_block_root)) = beacon_node_data { + match attestation_service + .beacon_nodes + .run_on_candidate_index(beacon_node_index, |beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::ATTESTATIONS_HTTP_GET], + ); + let data = beacon_node + .get_validator_attestation_data(slot, 0) + .await + .map_err(|e| format!("Failed to produce attestation data: {:?}", e))? + .data; + + if data.beacon_block_root != expected_block_root { + return Err(format!( + "Attestation block root mismatch: expected {:?}, got {:?}", + expected_block_root, data.beacon_block_root + )); + } + Ok(data) + }) + .await + { + Ok(data) => attestation_data_from_head_event = Some(data), + Err(error) => { + warn!(?error, "Failed to attest based on head event"); + } + } + } + + // If the beacon node that sent us the head failed to attest, wait until the attestation + // deadline then try all BNs. + let attestation_data = if let Some(attestation_data) = attestation_data_from_head_event { + attestation_data + } else { + let duration_to_deadline = self + .slot_clock + .duration_to_slot(slot + 1) + .and_then(|duration_to_next_slot| { + duration_to_next_slot + .checked_add(self.chain_spec.get_unaggregated_attestation_due()) + }) + .map(|next_slot_deadline| { + next_slot_deadline.saturating_sub(self.chain_spec.get_slot_duration()) + }) + .unwrap_or(Duration::from_secs(0)); + sleep(duration_to_deadline).await; + + attestation_service + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::ATTESTATIONS_HTTP_GET], + ); + let data = beacon_node + .get_validator_attestation_data(slot, 0) + .await + .map_err(|e| format!("Failed to produce attestation data: {:?}", e))? + .data; + Ok::<AttestationData, String>(data) + }) + .await + .map_err(|e| e.to_string())? + }; + + // Sign and publish attestations. + let publication_handle = self .inner .executor .spawn_handle( async move { - let attestation_data = attestation_service - .beacon_nodes - .first_success(|beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::ATTESTATIONS_HTTP_GET], - ); - beacon_node - .get_validator_attestation_data(slot, 0) - .await - .map_err(|e| format!("Failed to produce attestation data: {:?}", e)) - .map(|result| result.data) - }) - .await - .map_err(|e| e.to_string())?; - attestation_service .sign_and_publish_attestations( slot, @@ -231,7 +355,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, .await .map_err(|e| { crit!( - error = format!("{:?}", e), + error = e, slot = slot.as_u64(), "Error during attestation routine" ); @@ -239,15 +363,20 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, })?; Ok::<AttestationData, String>(attestation_data) }, - "unaggregated attestation production", + "unaggregated attestation publication", ) .ok_or("Failed to spawn attestation data task")?; // If a validator needs to publish an aggregate attestation, they must do so at 2/3 // through the slot. This delay triggers at this time + let duration_to_next_slot = self + .slot_clock + .duration_to_slot(slot + 1) + .ok_or("Unable to determine duration to next slot")?; let aggregate_production_instant = Instant::now() + duration_to_next_slot - .checked_sub(slot_duration / 3) + .checked_add(self.chain_spec.get_aggregate_attestation_due()) + .and_then(|offset| offset.checked_sub(self.chain_spec.get_slot_duration())) .unwrap_or_else(|| Duration::from_secs(0)); let aggregate_duties_by_committee_index: HashMap<CommitteeIndex, Vec<DutyAndProof>> = self @@ -267,7 +396,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, self.inner.executor.spawn( async move { // Log an error if the handle fails and return, skipping aggregates - let attestation_data = match attestation_data_handle.await { + let attestation_data = match publication_handle.await { Ok(Some(Ok(data))) => data, Ok(Some(Err(err))) => { error!(?err, "Attestation production failed"); @@ -310,7 +439,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, } #[instrument( - name = "handle_aggregates", + name = "lh_handle_aggregates", skip_all, fields(%slot, %committee_index) )] @@ -365,7 +494,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, /// /// The given `validator_duties` should already be filtered to only contain those that match /// `slot`. Critical errors will be logged if this is not the case. - #[instrument(skip_all, fields(%slot, %attestation_data.beacon_block_root))] + #[instrument(name = "lh_sign_and_publish_attestations", skip_all, fields(%slot, %attestation_data.beacon_block_root))] async fn sign_and_publish_attestations( &self, slot: Slot, @@ -383,160 +512,157 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, .ok_or("Unable to determine current slot from clock")? .epoch(S::E::slots_per_epoch()); - // Create futures to produce signed `Attestation` objects. - let attestation_data_ref = &attestation_data; - let signing_futures = validator_duties.iter().map(|duty_and_proof| { - async move { - let duty = &duty_and_proof.duty; - let attestation_data = attestation_data_ref; + // Make sure the target epoch is not higher than the current epoch to avoid potential attacks. + if attestation_data.target.epoch > current_epoch { + return Err(format!( + "Attestation target epoch {} is higher than current epoch {}", + attestation_data.target.epoch, current_epoch + )); + } - // Ensure that the attestation matches the duties. - if !duty.match_attestation_data::<S::E>(attestation_data, &self.chain_spec) { + // Create attestations for each validator duty. + let mut attestations_to_sign = Vec::with_capacity(validator_duties.len()); + + for duty_and_proof in validator_duties { + let duty = &duty_and_proof.duty; + + // Ensure that the attestation matches the duties. + if !duty.match_attestation_data::<S::E>(&attestation_data, &self.chain_spec) { + crit!( + validator = ?duty.pubkey, + duty_slot = %duty.slot, + attestation_slot = %attestation_data.slot, + duty_index = duty.committee_index, + attestation_index = attestation_data.index, + "Inconsistent validator duties during signing" + ); + continue; + } + + let attestation = match Attestation::empty_for_signing( + duty.committee_index, + duty.committee_length as usize, + attestation_data.slot, + attestation_data.beacon_block_root, + attestation_data.source, + attestation_data.target, + attestation_data.index != 0, + &self.chain_spec, + ) { + Ok(attestation) => attestation, + Err(err) => { crit!( validator = ?duty.pubkey, - duty_slot = %duty.slot, - attestation_slot = %attestation_data.slot, - duty_index = duty.committee_index, - attestation_index = attestation_data.index, - "Inconsistent validator duties during signing" + ?duty, + ?err, + "Invalid validator duties during signing" ); - return None; + continue; } + }; - let mut attestation = match Attestation::empty_for_signing( - duty.committee_index, - duty.committee_length as usize, - attestation_data.slot, - attestation_data.beacon_block_root, - attestation_data.source, - attestation_data.target, - &self.chain_spec, - ) { - Ok(attestation) => attestation, - Err(err) => { - crit!( - validator = ?duty.pubkey, - ?duty, - ?err, - "Invalid validator duties during signing" - ); - return None; - } - }; + attestations_to_sign.push(AttestationToSign { + validator_index: duty.validator_index, + pubkey: duty.pubkey, + validator_committee_index: duty.validator_committee_index as usize, + attestation, + }); + } - match self - .validator_store - .sign_attestation( - duty.pubkey, - duty.validator_committee_index as usize, - &mut attestation, - current_epoch, - ) - .await - { - Ok(()) => Some((attestation, duty.validator_index)), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - warn!( - info = "a validator may have recently been removed from this VC", - pubkey = ?pubkey, - validator = ?duty.pubkey, - slot = slot.as_u64(), - "Missing pubkey for attestation" - ); - None - } - Err(e) => { - crit!( - error = ?e, - validator = ?duty.pubkey, - slot = slot.as_u64(), - "Failed to sign attestation" - ); - None - } - } - } - .instrument(Span::current()) - }); - - // Execute all the futures in parallel, collecting any successful results. - let (ref attestations, ref validator_indices): (Vec<_>, Vec<_>) = join_all(signing_futures) - .instrument(info_span!( - "sign_attestations", - count = validator_duties.len() - )) - .await - .into_iter() - .flatten() - .unzip(); - - if attestations.is_empty() { - warn!("No attestations were published"); + if attestations_to_sign.is_empty() { + warn!("No valid attestations to sign"); return Ok(()); } + + let attestation_stream = self.validator_store.sign_attestations(attestations_to_sign); + tokio::pin!(attestation_stream); + let fork_name = self .chain_spec .fork_name_at_slot::<S::E>(attestation_data.slot); - // Post the attestations to the BN. - match self - .beacon_nodes - .request(ApiTopic::Attestations, |beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::ATTESTATIONS_HTTP_POST], - ); + // Publish each batch as it arrives from the stream. + let mut received_non_empty_batch = false; + while let Some(result) = attestation_stream.next().await { + match result { + Ok(batch) if !batch.is_empty() => { + received_non_empty_batch = true; - let single_attestations = attestations - .iter() - .zip(validator_indices) - .filter_map(|(a, i)| { - match a.to_single_attestation_with_attester_index(*i) { - Ok(a) => Some(a), - Err(e) => { - // This shouldn't happen unless BN and VC are out of sync with - // respect to the Electra fork. - error!( - error = ?e, - committee_index = attestation_data.index, - slot = slot.as_u64(), - "type" = "unaggregated", - "Unable to convert to SingleAttestation" - ); - None + let single_attestations = batch + .iter() + .filter_map(|(attester_index, attestation)| { + match attestation + .to_single_attestation_with_attester_index(*attester_index) + { + Ok(single_attestation) => Some(single_attestation), + Err(e) => { + // This shouldn't happen unless BN and VC are out of sync with + // respect to the Electra fork. + error!( + error = ?e, + committee_index = attestation_data.index, + slot = slot.as_u64(), + "type" = "unaggregated", + "Unable to convert to SingleAttestation" + ); + None + } } - } - }) - .collect::<Vec<_>>(); + }) + .collect::<Vec<_>>(); + let single_attestations = &single_attestations; + let validator_indices = single_attestations + .iter() + .map(|att| att.attester_index) + .collect::<Vec<_>>(); + let published_count = single_attestations.len(); - beacon_node - .post_beacon_pool_attestations_v2::<S::E>(single_attestations, fork_name) - .await - }) - .instrument(info_span!( - "publish_attestations", - count = attestations.len() - )) - .await - { - Ok(()) => info!( - count = attestations.len(), - validator_indices = ?validator_indices, - head_block = ?attestation_data.beacon_block_root, - committee_index = attestation_data.index, - slot = attestation_data.slot.as_u64(), - "type" = "unaggregated", - "Successfully published attestations" - ), - Err(e) => error!( - error = %e, - committee_index = attestation_data.index, - slot = slot.as_u64(), - "type" = "unaggregated", - "Unable to publish attestations" - ), + // Post the attestations to the BN. + match self + .beacon_nodes + .request(ApiTopic::Attestations, |beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::ATTESTATIONS_HTTP_POST], + ); + + beacon_node + .post_beacon_pool_attestations_v2::<S::E>( + single_attestations.clone(), + fork_name, + ) + .await + }) + .instrument(info_span!("publish_attestations", count = published_count)) + .await + { + Ok(()) => info!( + count = published_count, + validator_indices = ?validator_indices, + head_block = ?attestation_data.beacon_block_root, + committee_index = attestation_data.index, + slot = attestation_data.slot.as_u64(), + "type" = "unaggregated", + "Successfully published attestations" + ), + Err(e) => error!( + error = %e, + committee_index = attestation_data.index, + slot = slot.as_u64(), + "type" = "unaggregated", + "Unable to publish attestations" + ), + } + } + Err(e) => { + crit!(error = ?e, "Failed to sign attestations"); + } + _ => {} + } + } + + if !received_non_empty_batch { + warn!("No attestations were published"); } Ok(()) @@ -612,113 +738,103 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> AttestationService<S, .await .map_err(|e| e.to_string())?; - // Create futures to produce the signed aggregated attestations. - let signing_futures = validator_duties.iter().map(|duty_and_proof| async move { - let duty = &duty_and_proof.duty; - let selection_proof = duty_and_proof.selection_proof.as_ref()?; - - if !duty.match_attestation_data::<S::E>(attestation_data, &self.chain_spec) { - crit!("Inconsistent validator duties during signing"); - return None; - } - - match self - .validator_store - .produce_signed_aggregate_and_proof( - duty.pubkey, - duty.validator_index, - aggregated_attestation.clone(), - selection_proof.clone(), - ) - .await - { - Ok(aggregate) => Some(aggregate), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!(?pubkey, "Missing pubkey for aggregate"); - None - } - Err(e) => { - crit!( - error = ?e, - pubkey = ?duty.pubkey, - "Failed to sign aggregate" - ); - None - } - } - }); - - // Execute all the futures in parallel, collecting any successful results. - let aggregator_count = validator_duties + // Build the batch of aggregates to sign. + let aggregates_to_sign: Vec<_> = validator_duties .iter() - .filter(|d| d.selection_proof.is_some()) - .count(); - let signed_aggregate_and_proofs = join_all(signing_futures) - .instrument(info_span!("sign_aggregates", count = aggregator_count)) - .await - .into_iter() - .flatten() - .collect::<Vec<_>>(); + .filter_map(|duty_and_proof| { + let duty = &duty_and_proof.duty; + let selection_proof = duty_and_proof.selection_proof.as_ref()?; - if !signed_aggregate_and_proofs.is_empty() { - let signed_aggregate_and_proofs_slice = signed_aggregate_and_proofs.as_slice(); - match self - .beacon_nodes - .first_success(|beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::AGGREGATES_HTTP_POST], - ); - if fork_name.electra_enabled() { - beacon_node - .post_validator_aggregate_and_proof_v2( - signed_aggregate_and_proofs_slice, - fork_name, - ) - .await - } else { - beacon_node - .post_validator_aggregate_and_proof_v1( - signed_aggregate_and_proofs_slice, - ) - .await - } + if !duty.match_attestation_data::<S::E>(attestation_data, &self.chain_spec) { + crit!("Inconsistent validator duties during signing"); + return None; + } + + Some(AggregateToSign { + pubkey: duty.pubkey, + aggregator_index: duty.validator_index, + aggregate: aggregated_attestation.clone(), + selection_proof: selection_proof.clone(), }) - .instrument(info_span!( - "publish_aggregates", - count = signed_aggregate_and_proofs.len() - )) - .await - { - Ok(()) => { - for signed_aggregate_and_proof in signed_aggregate_and_proofs { - let attestation = signed_aggregate_and_proof.message().aggregate(); - info!( - aggregator = signed_aggregate_and_proof.message().aggregator_index(), - signatures = attestation.num_set_aggregation_bits(), - head_block = format!("{:?}", attestation.data().beacon_block_root), - committee_index = attestation.committee_index(), - slot = attestation.data().slot.as_u64(), - "type" = "aggregated", - "Successfully published attestation" - ); + }) + .collect(); + + // Sign aggregates. Returns a stream of batches. + let aggregate_stream = self + .validator_store + .sign_aggregate_and_proofs(aggregates_to_sign); + tokio::pin!(aggregate_stream); + + // Publish each batch as it arrives from the stream. + while let Some(result) = aggregate_stream.next().await { + match result { + Ok(batch) if !batch.is_empty() => { + let signed_aggregate_and_proofs = batch.as_slice(); + match self + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::AGGREGATES_HTTP_POST], + ); + if fork_name.electra_enabled() { + beacon_node + .post_validator_aggregate_and_proof_v2( + signed_aggregate_and_proofs, + fork_name, + ) + .await + } else { + beacon_node + .post_validator_aggregate_and_proof_v1( + signed_aggregate_and_proofs, + ) + .await + } + }) + .instrument(info_span!( + "publish_aggregates", + count = signed_aggregate_and_proofs.len() + )) + .await + { + Ok(()) => { + for signed_aggregate_and_proof in signed_aggregate_and_proofs { + let attestation = signed_aggregate_and_proof.message().aggregate(); + info!( + aggregator = + signed_aggregate_and_proof.message().aggregator_index(), + signatures = attestation.num_set_aggregation_bits(), + head_block = + format!("{:?}", attestation.data().beacon_block_root), + committee_index = attestation.committee_index(), + slot = attestation.data().slot.as_u64(), + "type" = "aggregated", + "Successfully published attestation" + ); + } + } + Err(e) => { + for signed_aggregate_and_proof in signed_aggregate_and_proofs { + let attestation = &signed_aggregate_and_proof.message().aggregate(); + crit!( + error = %e, + aggregator = signed_aggregate_and_proof + .message() + .aggregator_index(), + committee_index = attestation.committee_index(), + slot = attestation.data().slot.as_u64(), + "type" = "aggregated", + "Failed to publish attestation" + ); + } + } } } Err(e) => { - for signed_aggregate_and_proof in signed_aggregate_and_proofs { - let attestation = &signed_aggregate_and_proof.message().aggregate(); - crit!( - error = %e, - aggregator = signed_aggregate_and_proof.message().aggregator_index(), - committee_index = attestation.committee_index(), - slot = attestation.data().slot.as_u64(), - "type" = "aggregated", - "Failed to publish attestation" - ); - } + crit!(error = ?e, "Failed to sign aggregates"); } + _ => {} } } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 625f8db7cb..99e53b0100 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -1,9 +1,10 @@ use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, Error as FallbackError, Errors}; use bls::PublicKeyBytes; +use eth2::BeaconNodeHttpClient; use eth2::types::GraffitiPolicy; -use eth2::{BeaconNodeHttpClient, StatusCode}; use graffiti_file::{GraffitiFile, determine_graffiti}; use logging::crit; +use reqwest::StatusCode; use slot_clock::SlotClock; use std::fmt::Debug; use std::future::Future; @@ -13,6 +14,7 @@ use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Instrument, debug, error, info, info_span, instrument, trace, warn}; +use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{BlockType, ChainSpec, EthSpec, Graffiti, Slot}; use validator_store::{Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore}; @@ -333,7 +335,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> BlockService<S, T> { #[instrument(skip_all, fields(%slot, ?validator_pubkey))] async fn sign_and_publish_block( &self, - proposer_fallback: ProposerFallback<T>, + proposer_fallback: &ProposerFallback<T>, slot: Slot, graffiti: Option<Graffiti>, validator_pubkey: &PublicKeyBytes, @@ -402,7 +404,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> BlockService<S, T> { } #[instrument( - name = "block_proposal_duty_cycle", + name = "lh_block_proposal_duty_cycle", skip_all, fields(%slot, ?validator_pubkey) )] @@ -459,73 +461,145 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> BlockService<S, T> { info!(slot = slot.as_u64(), "Requesting unsigned block"); - // Request an SSZ block from all beacon nodes in order, returning on the first successful response. - // If all nodes fail, run a second pass falling back to JSON. - // - // Proposer nodes will always be tried last during each pass since it's likely that they don't have a - // great view of attestations on the network. - let ssz_block_response = proposer_fallback - .request_proposers_last(|beacon_node| async move { - let _get_timer = validator_metrics::start_timer_vec( - &validator_metrics::BLOCK_SERVICE_TIMES, - &[validator_metrics::BEACON_BLOCK_HTTP_GET], - ); - beacon_node - .get_validator_blocks_v3_ssz::<S::E>( - slot, - randao_reveal_ref, - graffiti.as_ref(), - builder_boost_factor, - self_ref.graffiti_policy, - ) - .await - }) - .await; + // Check if Gloas fork is active at this slot + let fork_name = self_ref.chain_spec.fork_name_at_slot::<S::E>(slot); - let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response, - Err(e) => { - warn!( - slot = slot.as_u64(), - error = %e, - "SSZ block production failed, falling back to JSON" - ); + let (block_proposer, unsigned_block) = if fork_name.gloas_enabled() { + // Use V4 block production for Gloas + // Request an SSZ block from all beacon nodes in order, returning on the first successful response. + // If all nodes fail, run a second pass falling back to JSON. + let ssz_block_response = proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + beacon_node + .get_validator_blocks_v4_ssz::<S::E>( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + }) + .await; - proposer_fallback - .request_proposers_last(|beacon_node| async move { - let _get_timer = validator_metrics::start_timer_vec( - &validator_metrics::BLOCK_SERVICE_TIMES, - &[validator_metrics::BEACON_BLOCK_HTTP_GET], - ); - let (json_block_response, _metadata) = beacon_node - .get_validator_blocks_v3::<S::E>( - slot, - randao_reveal_ref, - graffiti.as_ref(), - builder_boost_factor, - self_ref.graffiti_policy, - ) - .await - .map_err(|e| { - BlockError::Recoverable(format!( - "Error from beacon node when producing block: {:?}", - e - )) - })?; + let block_response = match ssz_block_response { + Ok((ssz_block_response, _metadata)) => ssz_block_response, + Err(e) => { + warn!( + slot = slot.as_u64(), + error = %e, + "SSZ V4 block production failed, falling back to JSON" + ); - Ok(json_block_response.data) - }) - .await - .map_err(BlockError::from)? - } - }; + proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + let (json_block_response, _metadata) = beacon_node + .get_validator_blocks_v4::<S::E>( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error from beacon node when producing block: {:?}", + e + )) + })?; - let (block_proposer, unsigned_block) = match block_response { - eth2::types::ProduceBlockV3Response::Full(block) => { - (block.block().proposer_index(), UnsignedBlock::Full(block)) - } - eth2::types::ProduceBlockV3Response::Blinded(block) => { - (block.proposer_index(), UnsignedBlock::Blinded(block)) + Ok(json_block_response.data) + }) + .await + .map_err(BlockError::from)? + } + }; + + // Gloas blocks don't have blobs (they're in the execution layer) + let block_contents = eth2::types::FullBlockContents::Block(block_response); + ( + block_contents.block().proposer_index(), + UnsignedBlock::Full(block_contents), + ) + } else { + // Use V3 block production for pre-Gloas forks + // Request an SSZ block from all beacon nodes in order, returning on the first successful response. + // If all nodes fail, run a second pass falling back to JSON. + // + // Proposer nodes will always be tried last during each pass since it's likely that they don't have a + // great view of attestations on the network. + let ssz_block_response = proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + beacon_node + .get_validator_blocks_v3_ssz::<S::E>( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + }) + .await; + + let block_response = match ssz_block_response { + Ok((ssz_block_response, _metadata)) => ssz_block_response, + Err(e) => { + warn!( + slot = slot.as_u64(), + error = %e, + "SSZ block production failed, falling back to JSON" + ); + + proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + let (json_block_response, _metadata) = beacon_node + .get_validator_blocks_v3::<S::E>( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error from beacon node when producing block: {:?}", + e + )) + })?; + + Ok(json_block_response.data) + }) + .await + .map_err(BlockError::from)? + } + }; + + match block_response { + eth2::types::ProduceBlockV3Response::Full(block) => { + (block.block().proposer_index(), UnsignedBlock::Full(block)) + } + eth2::types::ProduceBlockV3Response::Blinded(block) => { + (block.proposer_index(), UnsignedBlock::Blinded(block)) + } } }; @@ -538,7 +612,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> BlockService<S, T> { self_ref .sign_and_publish_block( - proposer_fallback, + &proposer_fallback, slot, graffiti, &validator_pubkey, @@ -546,6 +620,108 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> BlockService<S, T> { ) .await?; + // TODO(gloas) we only need to fetch, sign and publish the envelope in the local building case. + // Right now we always default to local building. Once we implement trustless/trusted builder logic + // we should check the bid for index == BUILDER_INDEX_SELF_BUILD + if fork_name.gloas_enabled() { + self_ref + .fetch_sign_and_publish_payload_envelope( + &proposer_fallback, + slot, + &validator_pubkey, + ) + .await?; + } + + Ok(()) + } + + /// Fetch, sign, and publish the execution payload envelope for Gloas. + /// This should be called after the block has been published. + /// + /// TODO(gloas): For multi-BN setups, we need to track which beacon node produced the block + /// and fetch the envelope from that same node. The envelope is cached per-BN, + /// so fetching from a different BN than the one that built the block will fail. + /// See: https://github.com/sigp/lighthouse/pull/8313 + #[instrument(skip_all)] + async fn fetch_sign_and_publish_payload_envelope( + &self, + _proposer_fallback: &ProposerFallback<T>, + slot: Slot, + validator_pubkey: &PublicKeyBytes, + ) -> Result<(), BlockError> { + info!(slot = slot.as_u64(), "Fetching execution payload envelope"); + + // Fetch the envelope from the beacon node. Use builder_index=BUILDER_INDEX_SELF_BUILD for local building. + // TODO(gloas): Use proposer_fallback once multi-BN is supported. + let envelope = self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .get_validator_execution_payload_envelope_ssz::<S::E>( + slot, + BUILDER_INDEX_SELF_BUILD, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error fetching execution payload envelope: {:?}", + e + )) + }) + }) + .await?; + + info!( + slot = slot.as_u64(), + beacon_block_root = %envelope.beacon_block_root, + "Received execution payload envelope, signing" + ); + + // Sign the envelope + let signed_envelope = self + .validator_store + .sign_execution_payload_envelope(*validator_pubkey, envelope) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error signing execution payload envelope: {:?}", + e + )) + })?; + + info!( + slot = slot.as_u64(), + "Signed execution payload envelope, publishing" + ); + + let fork_name = self.chain_spec.fork_name_at_slot::<S::E>(slot); + + // Publish the signed envelope + // TODO(gloas): Use proposer_fallback once multi-BN is supported. + self.beacon_nodes + .first_success(|beacon_node| { + let signed_envelope = signed_envelope.clone(); + async move { + beacon_node + .post_beacon_execution_payload_envelope_ssz(&signed_envelope, fork_name) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error publishing execution payload envelope: {:?}", + e + )) + }) + } + }) + .await?; + + info!( + slot = slot.as_u64(), + beacon_block_root = %signed_envelope.message.beacon_block_root, + "Successfully published signed execution payload envelope" + ); + Ok(()) } diff --git a/validator_client/validator_services/src/duties_service.rs b/validator_client/validator_services/src/duties_service.rs index ff314de60f..5aa2c5b3ae 100644 --- a/validator_client/validator_services/src/duties_service.rs +++ b/validator_client/validator_services/src/duties_service.rs @@ -13,11 +13,11 @@ use beacon_node_fallback::{ApiTopic, BeaconNodeFallback}; use bls::PublicKeyBytes; use eth2::types::{ AttesterData, BeaconCommitteeSelection, BeaconCommitteeSubscription, DutiesResponse, - InclusionListDuty, ProposerData, StateId, ValidatorId, + InclusionListDuty, ProposerData, PtcDuty, StateId, ValidatorId, }; use futures::{ StreamExt, - stream::{self, FuturesUnordered}, + stream::{self, FuturesUnordered, TryStreamExt}, }; use parking_lot::{RwLock, RwLockWriteGuard}; use safe_arith::{ArithError, SafeArith}; @@ -46,6 +46,7 @@ const VALIDATOR_METRICS_MIN_COUNT: usize = 64; /// The initial request is used to determine if further requests are required, so that it /// reduces the amount of data that needs to be transferred. const INITIAL_DUTIES_QUERY_SIZE: usize = 1; +const INITIAL_PTC_DUTIES_QUERY_SIZE: usize = 1; /// Offsets from the attestation duty slot at which a subscription should be sent. const ATTESTATION_SUBSCRIPTION_OFFSETS: [u64; 8] = [3, 4, 5, 6, 7, 8, 16, 32]; @@ -83,6 +84,7 @@ const _: () = assert!(ATTESTATION_SUBSCRIPTION_OFFSETS[0] > MIN_ATTESTATION_SUBS pub enum Error<T> { UnableToReadSlotClock, FailedToDownloadAttesters(#[allow(dead_code)] String), + FailedToDownloadPtc(#[allow(dead_code)] String), FailedToProduceSelectionProof(#[allow(dead_code)] ValidatorStoreError<T>), InvalidModulo(#[allow(dead_code)] ArithError), Arith(#[allow(dead_code)] ArithError), @@ -142,71 +144,15 @@ impl Default for SelectionProofConfig { /// Create a selection proof for `duty`. /// /// Return `Ok(None)` if the attesting validator is not an aggregator. -async fn make_selection_proof<S: ValidatorStore + 'static, T: SlotClock>( +async fn make_selection_proof<S: ValidatorStore>( duty: &AttesterData, validator_store: &S, spec: &ChainSpec, - beacon_nodes: &Arc<BeaconNodeFallback<T>>, - config: &SelectionProofConfig, ) -> Result<Option<SelectionProof>, Error<S::Error>> { - let selection_proof = if config.selections_endpoint { - let beacon_committee_selection = BeaconCommitteeSelection { - validator_index: duty.validator_index, - slot: duty.slot, - // This is partial selection proof - selection_proof: validator_store - .produce_selection_proof(duty.pubkey, duty.slot) - .await - .map_err(Error::FailedToProduceSelectionProof)? - .into(), - }; - // Call the endpoint /eth/v1/validator/beacon_committee_selections - // by sending the BeaconCommitteeSelection that contains partial selection proof - // The middleware should return BeaconCommitteeSelection that contains full selection proof - let middleware_response = beacon_nodes - .first_success(|beacon_node| { - let selection_data = beacon_committee_selection.clone(); - debug!( - "validator_index" = duty.validator_index, - "slot" = %duty.slot, - "partial selection proof" = ?beacon_committee_selection.selection_proof, - "Sending selection to middleware" - ); - async move { - beacon_node - .post_validator_beacon_committee_selections(&[selection_data]) - .await - } - }) - .await; - - let response_data = middleware_response - .map_err(|e| { - Error::FailedToProduceSelectionProof(ValidatorStoreError::Middleware(e.to_string())) - })? - .data - .pop() - .ok_or_else(|| { - Error::FailedToProduceSelectionProof(ValidatorStoreError::Middleware(format!( - "attestation selection proof - empty response for validator {}", - duty.validator_index - ))) - })?; - - debug!( - "validator_index" = response_data.validator_index, - "slot" = %response_data.slot, - // The selection proof from middleware response will be a full selection proof - "full selection proof" = ?response_data.selection_proof, - "Received selection from middleware" - ); - SelectionProof::from(response_data.selection_proof) - } else { - validator_store - .produce_selection_proof(duty.pubkey, duty.slot) - .await - .map_err(Error::FailedToProduceSelectionProof)? - }; + let selection_proof = validator_store + .produce_selection_proof(duty.pubkey, duty.slot) + .await + .map_err(Error::FailedToProduceSelectionProof)?; selection_proof .is_aggregator(duty.committee_length as usize, spec) @@ -222,6 +168,69 @@ async fn make_selection_proof<S: ValidatorStore + 'static, T: SlotClock>( }) } +/// Create a Vec<BeaconCommitteeSelection> for every epoch +/// so that when calling the selections_endpoint later, it calls once per epoch with duties of all slots in that epoch +async fn make_beacon_committee_selection<S: ValidatorStore, T: SlotClock>( + duties_service: &Arc<DutiesService<S, T>>, + duties: &[AttesterData], +) -> Result<Vec<BeaconCommitteeSelection>, Error<S::Error>> { + // collect the BeaconCommitteeSelection in duties + let beacon_committee_selections = duties + .iter() + .map(|duty| { + let validator_store = &duties_service.validator_store; + async move { + let partial_selection_proof = validator_store + .produce_selection_proof(duty.pubkey, duty.slot) + .await + .map_err(Error::FailedToProduceSelectionProof)?; + Ok::<BeaconCommitteeSelection, Error<S::Error>>(BeaconCommitteeSelection { + validator_index: duty.validator_index, + slot: duty.slot, + selection_proof: partial_selection_proof.into(), + }) + } + }) + .collect::<FuturesUnordered<_>>() + .try_collect::<Vec<_>>() + .await?; + + let epoch = duties + .first() + .map(|attester_data| attester_data.slot.epoch(S::E::slots_per_epoch())) + .unwrap_or_default(); + + debug!( + %epoch, + count = beacon_committee_selections.len(), + "Sending selections to middleware" + ); + + let selections = duties_service + .beacon_nodes + .first_success(|beacon_node| { + let selections = beacon_committee_selections.clone(); + async move { + beacon_node + .post_validator_beacon_committee_selections(&selections) + .await + } + }) + .await + .map_err(|e| { + Error::FailedToProduceSelectionProof(ValidatorStoreError::Middleware(e.to_string())) + })? + .data; + + debug!( + %epoch, + count = beacon_committee_selections.len(), + "Received selections from middleware" + ); + + Ok(selections) +} + impl DutyAndProof { /// Create a new `DutyAndProof` with the selection proof waiting to be filled in. pub fn new_without_selection_proof(duty: AttesterData, current_slot: Slot) -> Self { @@ -279,6 +288,7 @@ type AttesterMap = HashMap<PublicKeyBytes, HashMap<Epoch, (DependentRoot, DutyAn type ProposerMap = HashMap<Epoch, (DependentRoot, Vec<ProposerData>)>; type InclusionListDutiesMap = HashMap<PublicKeyBytes, HashMap<Epoch, (DependentRoot, InclusionListDuty)>>; +type PtcMap = HashMap<Epoch, (DependentRoot, Vec<PtcDuty>)>; pub struct DutiesServiceBuilder<S, T> { /// Provides the canonical list of locally-managed validators. @@ -381,6 +391,7 @@ impl<S, T> DutiesServiceBuilder<S, T> { proposers: Default::default(), sync_duties: SyncDutiesMap::new(self.sync_selection_proof_config), inclusion_list_duties: Default::default(), + ptc_duties: Default::default(), validator_store: self .validator_store .ok_or("Cannot build DutiesService without validator_store")?, @@ -413,6 +424,8 @@ pub struct DutiesService<S, T> { /// Maps a validator public key to their inclusion list committee duties for each epoch. pub inclusion_list_duties: RwLock<InclusionListDutiesMap>, pub sync_duties: SyncDutiesMap, + /// Maps an epoch to PTC duties for locally-managed validators. + pub ptc_duties: RwLock<PtcMap>, /// Provides the canonical list of locally-managed validators. pub validator_store: Arc<S>, /// Maps unknown validator pubkeys to the next slot time when a poll should be conducted again. @@ -464,13 +477,22 @@ impl<S: ValidatorStore, T: SlotClock + 'static> DutiesService<S, T> { .voting_pubkeys(DoppelgangerStatus::only_safe); self.attesters .read() - .iter() - .filter_map(|(_, map)| map.get(&epoch)) + .values() + .filter_map(|map| map.get(&epoch)) .map(|(_, duty_and_proof)| duty_and_proof) .filter(|duty_and_proof| signing_pubkeys.contains(&duty_and_proof.duty.pubkey)) .count() } + /// Returns the total number of validators that have PTC duties in the given epoch. + pub fn ptc_count(&self, epoch: Epoch) -> usize { + self.ptc_duties + .read() + .get(&epoch) + .map(|(_, duties)| duties.len()) + .unwrap_or(0) + } + /// Returns the total number of validators that are in a doppelganger detection period. pub fn doppelganger_detecting_count(&self) -> usize { self.validator_store @@ -517,8 +539,8 @@ impl<S: ValidatorStore, T: SlotClock + 'static> DutiesService<S, T> { self.attesters .read() - .iter() - .filter_map(|(_, map)| map.get(&epoch)) + .values() + .filter_map(|map| map.get(&epoch)) .map(|(_, duty_and_proof)| duty_and_proof) .filter(|duty_and_proof| { duty_and_proof.duty.slot == slot @@ -556,6 +578,25 @@ impl<S: ValidatorStore, T: SlotClock + 'static> DutiesService<S, T> { self.enable_high_validator_count_metrics || self.total_validator_count() <= VALIDATOR_METRICS_MIN_COUNT } + + /// Get PTC duties for a specific slot. + /// + /// Returns duties for local validators who have PTC assignments at the given slot. + pub fn get_ptc_duties_for_slot(&self, slot: Slot) -> Vec<PtcDuty> { + let epoch = slot.epoch(S::E::slots_per_epoch()); + + self.ptc_duties + .read() + .get(&epoch) + .map(|(_, ptc_duties)| { + ptc_duties + .iter() + .filter(|ptc_duty| ptc_duty.slot == slot) + .cloned() + .collect() + }) + .unwrap_or_default() + } } /// Start the service that periodically polls the beacon node for validator duties. This will start @@ -711,6 +752,61 @@ pub fn start_update_service<S: ValidatorStore + 'static, T: SlotClock + 'static> }, "duties_service_inclusion_list_committee", ); + + // Spawn the task which keeps track of local PTC duties. + // Only start PTC duties service if Gloas fork is scheduled. + if core_duties_service.spec.is_gloas_scheduled() { + let duties_service = core_duties_service.clone(); + core_duties_service.executor.spawn( + async move { + loop { + // Check if we've reached the Gloas fork epoch before polling + let Some(current_slot) = duties_service.slot_clock.now() else { + // Unable to read slot clock, sleep and try again + sleep(duties_service.slot_clock.slot_duration()).await; + continue; + }; + + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + let Some(gloas_fork_epoch) = duties_service.spec.gloas_fork_epoch else { + // Gloas fork epoch not configured, should not reach here + break; + }; + + if current_epoch + 1 < gloas_fork_epoch { + // Wait until the next slot and check again + if let Some(duration) = duties_service.slot_clock.duration_to_next_slot() { + sleep(duration).await; + } else { + sleep(duties_service.slot_clock.slot_duration()).await; + } + continue; + } + + if let Err(e) = poll_beacon_ptc_attesters(&duties_service).await { + error!( + error = ?e, + "Failed to poll PTC duties" + ); + } + + // Wait until the next slot before polling again. + // This doesn't mean that the beacon node will get polled every slot + // as the PTC duties service will return early if it deems it already has + // enough information. + if let Some(duration) = duties_service.slot_clock.duration_to_next_slot() { + sleep(duration).await; + } else { + // Just sleep for one slot if we are unable to read the system clock, this gives + // us an opportunity for the clock to eventually come good. + sleep(duties_service.slot_clock.slot_duration()).await; + continue; + } + } + }, + "duties_service_ptc", + ); + } } /// Iterate through all the voting pubkeys in the `ValidatorStore` and attempt to learn any unknown @@ -943,8 +1039,8 @@ async fn poll_beacon_attesters<S: ValidatorStore + 'static, T: SlotClock + 'stat duties_service .attesters .read() - .iter() - .filter_map(|(_, map)| map.get(epoch)) + .values() + .filter_map(|map| map.get(epoch)) .filter(|(_, duty_and_proof)| { duty_and_proof .subscription_slots @@ -1331,6 +1427,26 @@ fn process_duty_and_proof<S: ValidatorStore>( } } +async fn post_validator_duties_ptc<S: ValidatorStore, T: SlotClock + 'static>( + duties_service: &Arc<DutiesService<S, T>>, + epoch: Epoch, + validator_indices: &[u64], +) -> Result<DutiesResponse<Vec<PtcDuty>>, Error<S::Error>> { + duties_service + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::PTC_DUTIES_HTTP_POST], + ); + beacon_node + .post_validator_duties_ptc(epoch, validator_indices) + .await + }) + .await + .map_err(|e| Error::FailedToDownloadPtc(e.to_string())) +} + /// Compute the attestation selection proofs for the `duties` and add them to the `attesters` map. /// /// Duties are computed in batches each slot. If a re-org is detected then the process will @@ -1343,14 +1459,21 @@ async fn fill_in_selection_proofs<S: ValidatorStore + 'static, T: SlotClock + 's // Sort duties by slot in a BTreeMap. let mut duties_by_slot: BTreeMap<Slot, Vec<_>> = BTreeMap::new(); - for duty in duties { - duties_by_slot.entry(duty.slot).or_default().push(duty); + for duty in &duties { + duties_by_slot + .entry(duty.slot) + .or_default() + .push(duty.clone()); } // At halfway through each slot when nothing else is likely to be getting signed, sign a batch // of selection proofs and insert them into the duties service `attesters` map. let slot_clock = &duties_service.slot_clock; + // Create a HashMap for BeaconCommitteeSelection to match the duty later for distributed case involving middleware + let mut selection_hashmap = HashMap::new(); + let mut call_selection_endpoint = false; + while !duties_by_slot.is_empty() { if let Some(duration) = slot_clock.duration_to_next_slot() { sleep( @@ -1389,10 +1512,77 @@ async fn fill_in_selection_proofs<S: ValidatorStore + 'static, T: SlotClock + 's &[validator_metrics::ATTESTATION_SELECTION_PROOFS], ); - // In distributed case, we want to send all partial selection proofs to the middleware to determine aggregation duties, - // as the middleware will need to have a threshold of partial selection proofs to be able to return the full selection proof - // Thus, sign selection proofs in parallel in distributed case; Otherwise, sign them serially in non-distributed (normal) case - if duties_service.selection_proof_config.parallel_sign { + // for distributed case that uses the selections_endpoint + if duties_service.selection_proof_config.selections_endpoint { + // Using lookahead_slot to determine if it is the first slot of an epoch + let is_lookahead_slot_epoch_start = lookahead_slot % S::E::slots_per_epoch() == 0; + + // Call the selection endpoint only at the first slot of an epoch or when it errors + if is_lookahead_slot_epoch_start || call_selection_endpoint { + let beacon_committee_selections = + make_beacon_committee_selection(&duties_service, &duties).await; + + let selections = match beacon_committee_selections { + Ok(selections) => selections, + Err(e) => { + error!( + error = ?e, + "Failed to fetch selection proofs" + ); + // If calling the endpoint fails, change to true so that it will retry the next slot + call_selection_endpoint = true; + continue; + } + }; + + for selection in &selections { + // This is a full_selection_proof returned by middleware + let selection_proof = + SelectionProof::from(selection.selection_proof.clone()); + selection_hashmap + .insert((selection.validator_index, selection.slot), selection_proof); + } + // Once we have the selection_proof, we don't call the selections_endpoint again + call_selection_endpoint = false; + } + + for duty in relevant_duties.into_values().flatten() { + let key = (duty.validator_index, duty.slot); + + let result = if let Some(selection_proof) = selection_hashmap.remove(&key) { + match selection_proof + .is_aggregator(duty.committee_length as usize, &duties_service.spec) + .map_err(Error::<S::Error>::InvalidModulo) + { + // Aggregator, return the result + Ok(true) => Ok((duty, Some(selection_proof))), + // Not an aggregator, do nothing and continue + Ok(false) => continue, + Err(_) => return, + } + } else { + Err(Error::FailedToProduceSelectionProof( + ValidatorStoreError::Middleware(format!( + "Missing selection proof for validator {} slot {}", + duty.validator_index, duty.slot + )), + )) + }; + + let mut attesters = duties_service.attesters.write(); + // if process_duty_and_proof returns false, exit the loop + if !process_duty_and_proof::<S>( + &mut attesters, + result, + dependent_root, + current_slot, + ) { + return; + } + } + } + // For distributed case that uses parallel_sign + else if duties_service.selection_proof_config.parallel_sign { let mut duty_and_proof_results = relevant_duties .into_values() .flatten() @@ -1401,8 +1591,6 @@ async fn fill_in_selection_proofs<S: ValidatorStore + 'static, T: SlotClock + 's &duty, duties_service.validator_store.as_ref(), &duties_service.spec, - &duties_service.beacon_nodes, - &duties_service.selection_proof_config, ) .await?; Ok((duty, opt_selection_proof)) @@ -1429,8 +1617,6 @@ async fn fill_in_selection_proofs<S: ValidatorStore + 'static, T: SlotClock + 's &duty, duties_service.validator_store.as_ref(), &duties_service.spec, - &duties_service.beacon_nodes, - &duties_service.selection_proof_config, ) .await?; Ok((duty, opt_selection_proof)) @@ -1857,6 +2043,209 @@ async fn poll_beacon_proposers<S: ValidatorStore, T: SlotClock + 'static>( Ok(()) } +/// Query the beacon node for ptc duties for any known validators. +async fn poll_beacon_ptc_attesters<S: ValidatorStore + 'static, T: SlotClock + 'static>( + duties_service: &Arc<DutiesService<S, T>>, +) -> Result<(), Error<S::Error>> { + let current_epoch_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_CURRENT_EPOCH], + ); + + let current_slot = duties_service + .slot_clock + .now() + .ok_or(Error::UnableToReadSlotClock)?; + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + + // Collect *all* pubkeys, even those undergoing doppelganger protection. + let local_pubkeys: HashSet<_> = duties_service + .validator_store + .voting_pubkeys(DoppelgangerStatus::ignored); + + let local_indices = { + let mut local_indices = Vec::with_capacity(local_pubkeys.len()); + + for &pubkey in &local_pubkeys { + if let Some(validator_index) = duties_service.validator_store.validator_index(&pubkey) { + local_indices.push(validator_index) + } + } + local_indices + }; + + // Poll for current epoch + if let Err(e) = poll_beacon_ptc_attesters_for_epoch( + duties_service, + current_epoch, + &local_indices, + &local_pubkeys, + ) + .await + { + error!( + %current_epoch, + request_epoch = %current_epoch, + err = ?e, + "Failed to download PTC duties" + ); + } + drop(current_epoch_timer); + let next_epoch_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_NEXT_EPOCH], + ); + + // Poll for next epoch + let next_epoch = current_epoch + 1; + if let Err(e) = poll_beacon_ptc_attesters_for_epoch( + duties_service, + next_epoch, + &local_indices, + &local_pubkeys, + ) + .await + { + error!( + %current_epoch, + request_epoch = %next_epoch, + err = ?e, + "Failed to download PTC duties" + ); + } + drop(next_epoch_timer); + + // Prune old duties. + duties_service + .ptc_duties + .write() + .retain(|&epoch, _| epoch + HISTORICAL_DUTIES_EPOCHS >= current_epoch); + + Ok(()) +} + +/// For the given `local_indices` and `local_pubkeys`, download the PTC duties for the given `epoch` and +/// store them in `duties_service.ptc_duties` using bandwidth optimization. +async fn poll_beacon_ptc_attesters_for_epoch< + S: ValidatorStore + 'static, + T: SlotClock + 'static, +>( + duties_service: &Arc<DutiesService<S, T>>, + epoch: Epoch, + local_indices: &[u64], + local_pubkeys: &HashSet<PublicKeyBytes>, +) -> Result<(), Error<S::Error>> { + // No need to bother the BN if we don't have any validators. + if local_indices.is_empty() { + debug!( + %epoch, + "No validators, not downloading PTC duties" + ); + return Ok(()); + } + + let fetch_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_FETCH], + ); + + // TODO(gloas) Unlike attester duties which use `get_uninitialized_validators` to detect + // newly-added validators, PTC duties only check dependent_root changes. Validators added + // mid-epoch won't get PTC duties until the next epoch boundary. We should probably fix this. + let initial_indices_to_request = + &local_indices[0..min(INITIAL_PTC_DUTIES_QUERY_SIZE, local_indices.len())]; + + let response = + post_validator_duties_ptc(duties_service, epoch, initial_indices_to_request).await?; + let dependent_root = response.dependent_root; + + // Check if we need to update duties for this epoch and collect validators to update. + // We update if we have no epoch data OR if the dependent_root changed. + let validators_to_update = { + // Avoid holding the read-lock for any longer than required. + let ptc_duties = duties_service.ptc_duties.read(); + let needs_update = ptc_duties.get(&epoch).is_none_or(|(prior_root, _duties)| { + // Update if dependent_root changed + *prior_root != dependent_root + }); + + if needs_update { + local_pubkeys.iter().collect::<Vec<_>>() + } else { + Vec::new() + } + }; + + if validators_to_update.is_empty() { + // No validators have conflicting (epoch, dependent_root) values for this epoch. + return Ok(()); + } + + // Make a request for all indices that require updating which we have not already made a request for. + let indices_to_request = validators_to_update + .iter() + .filter_map(|pubkey| duties_service.validator_store.validator_index(pubkey)) + .filter(|validator_index| !initial_indices_to_request.contains(validator_index)) + .collect::<Vec<_>>(); + + // Filter the initial duties by their relevance so that we don't hit warnings about + // overwriting duties. + let new_initial_duties = response + .data + .into_iter() + .filter(|duty| validators_to_update.contains(&&duty.pubkey)); + + let mut new_duties = if !indices_to_request.is_empty() { + post_validator_duties_ptc(duties_service, epoch, indices_to_request.as_slice()) + .await? + .data + } else { + vec![] + }; + new_duties.extend(new_initial_duties); + + drop(fetch_timer); + + let _store_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_STORE], + ); + + debug!( + %dependent_root, + num_new_duties = new_duties.len(), + "Downloaded PTC duties" + ); + + // Update duties - we only reach here if dependent_root changed or epoch is missing + let mut ptc_duties = duties_service.ptc_duties.write(); + + match ptc_duties.entry(epoch) { + hash_map::Entry::Occupied(mut entry) => { + // Dependent root must have changed, so we do complete replacement. + // We cannot support partial updates for the same dependent_root. + // The beacon node may return incomplete duty lists and we cannot distinguish between "no duties" and + // "duties not included in this response". We could query all local validators in each + // `post_validator_duties_ptc` call regardless of dependent_root changes, but the bandwidth + // cost is likely not justified since PTC assignments are sparse. + let (existing_root, _existing_duties) = entry.get(); + debug!( + old_root = %existing_root, + new_root = %dependent_root, + "PTC dependent root changed, replacing all duties" + ); + + *entry.get_mut() = (dependent_root, new_duties); + } + hash_map::Entry::Vacant(entry) => { + // No existing duties for this epoch + entry.insert((dependent_root, new_duties)); + } + } + + Ok(()) +} + /// Notify the block service if it should produce a block. async fn notify_block_production_service<S: ValidatorStore>( current_slot: Slot, diff --git a/validator_client/validator_services/src/inclusion_list_service.rs b/validator_client/validator_services/src/inclusion_list_service.rs index b29f119bd6..c447a120d3 100644 --- a/validator_client/validator_services/src/inclusion_list_service.rs +++ b/validator_client/validator_services/src/inclusion_list_service.rs @@ -136,7 +136,7 @@ impl<S, T> Deref for InclusionListService<S, T> { impl<S: ValidatorStore + 'static, T: SlotClock + 'static> InclusionListService<S, T> { /// Starts the service which periodically produces inclusion lists. pub fn start_update_service(self, spec: &ChainSpec) -> Result<(), String> { - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let duration_to_next_slot = self .slot_clock diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index 6412464f8e..83a2abe2b7 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -4,6 +4,7 @@ pub mod duties_service; pub mod inclusion_list_service; pub mod latency_service; pub mod notifier_service; +pub mod payload_attestation_service; pub mod preparation_service; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/notifier_service.rs b/validator_client/validator_services/src/notifier_service.rs index 9c5f019c7a..e6e7a67864 100644 --- a/validator_client/validator_services/src/notifier_service.rs +++ b/validator_client/validator_services/src/notifier_service.rs @@ -2,7 +2,7 @@ use crate::duties_service::DutiesService; use slot_clock::SlotClock; use std::sync::Arc; use task_executor::TaskExecutor; -use tokio::time::{Duration, sleep}; +use tokio::time::sleep; use tracing::{debug, error, info}; use types::{ChainSpec, EthSpec}; use validator_metrics::set_gauge; @@ -14,7 +14,7 @@ pub fn spawn_notifier<S: ValidatorStore + 'static, T: SlotClock + 'static>( executor: TaskExecutor, spec: &ChainSpec, ) -> Result<(), String> { - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let interval_fut = async move { loop { @@ -109,6 +109,7 @@ pub async fn notify<S: ValidatorStore, T: SlotClock + 'static>( let total_validators = duties_service.total_validator_count(); let proposing_validators = duties_service.proposer_count(epoch); let attesting_validators = duties_service.attester_count(epoch); + let ptc_validators = duties_service.ptc_count(epoch); let doppelganger_detecting_validators = duties_service.doppelganger_detecting_count(); if doppelganger_detecting_validators > 0 { @@ -126,6 +127,7 @@ pub async fn notify<S: ValidatorStore, T: SlotClock + 'static>( } else if total_validators == attesting_validators { info!( current_epoch_proposers = proposing_validators, + current_epoch_ptc = ptc_validators, active_validators = attesting_validators, total_validators = total_validators, %epoch, @@ -135,6 +137,7 @@ pub async fn notify<S: ValidatorStore, T: SlotClock + 'static>( } else if attesting_validators > 0 { info!( current_epoch_proposers = proposing_validators, + current_epoch_ptc = ptc_validators, active_validators = attesting_validators, total_validators = total_validators, %epoch, @@ -146,7 +149,7 @@ pub async fn notify<S: ValidatorStore, T: SlotClock + 'static>( validators = total_validators, %epoch, %slot, - "Awaiting activation" + "All validators inactive" ); } } else { diff --git a/validator_client/validator_services/src/payload_attestation_service.rs b/validator_client/validator_services/src/payload_attestation_service.rs new file mode 100644 index 0000000000..24949edc1f --- /dev/null +++ b/validator_client/validator_services/src/payload_attestation_service.rs @@ -0,0 +1,243 @@ +use crate::duties_service::DutiesService; +use beacon_node_fallback::BeaconNodeFallback; +use logging::crit; +use slot_clock::SlotClock; +use std::ops::Deref; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec}; +use validator_store::ValidatorStore; + +pub struct Inner<S, T> { + duties_service: Arc<DutiesService<S, T>>, + validator_store: Arc<S>, + slot_clock: T, + beacon_nodes: Arc<BeaconNodeFallback<T>>, + executor: TaskExecutor, + chain_spec: Arc<ChainSpec>, +} + +pub struct PayloadAttestationService<S, T> { + inner: Arc<Inner<S, T>>, +} + +impl<S, T> Clone for PayloadAttestationService<S, T> { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl<S, T> Deref for PayloadAttestationService<S, T> { + type Target = Inner<S, T>; + + fn deref(&self) -> &Self::Target { + self.inner.deref() + } +} + +impl<S: ValidatorStore + 'static, T: SlotClock + 'static> PayloadAttestationService<S, T> { + pub fn new( + duties_service: Arc<DutiesService<S, T>>, + validator_store: Arc<S>, + slot_clock: T, + beacon_nodes: Arc<BeaconNodeFallback<T>>, + executor: TaskExecutor, + chain_spec: Arc<ChainSpec>, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.chain_spec.get_slot_duration(); + let payload_attestation_due = self.chain_spec.get_payload_attestation_due(); + + info!( + payload_attestation_due_ms = payload_attestation_due.as_millis(), + "Payload attestation service started" + ); + + let executor = self.executor.clone(); + + let interval_fut = async move { + loop { + let Some(duration_to_next_slot) = self.slot_clock.duration_to_next_slot() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + let Some(current_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after trigger"); + continue; + }; + + if !self + .chain_spec + .fork_name_at_slot::<S::E>(current_slot) + .gloas_enabled() + { + let duration_to_next_epoch = self + .slot_clock + .duration_to_next_epoch(S::E::slots_per_epoch()) + .unwrap_or_else(|| { + self.chain_spec.get_slot_duration() * S::E::slots_per_epoch() as u32 + }); + sleep(duration_to_next_epoch).await; + continue; + } + + sleep(duration_to_next_slot + payload_attestation_due).await; + + let Some(attestation_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after sleep"); + continue; + }; + + let service = self.clone(); + self.executor.spawn( + async move { + service.produce_and_publish(attestation_slot).await; + }, + "payload_attestation_producer", + ); + } + }; + + executor.spawn(interval_fut, "payload_attestation_service"); + Ok(()) + } + + async fn produce_and_publish(&self, slot: types::Slot) { + let duties = self.duties_service.get_ptc_duties_for_slot(slot); + + if duties.is_empty() { + return; + } + + debug!( + %slot, + duty_count = duties.len(), + "Producing payload attestations" + ); + + let attestation_data = match self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .get_validator_payload_attestation_data(slot) + .await + .map_err(|e| format!("Failed to get payload attestation data: {e:?}")) + .map(|resp| resp.into_data()) + }) + .await + { + Ok(data) => data, + Err(e) => { + crit!( + error = %e, + %slot, + "Failed to produce payload attestation data" + ); + return; + } + }; + + debug!( + %slot, + beacon_block_root = ?attestation_data.beacon_block_root, + payload_present = attestation_data.payload_present, + "Received payload attestation data" + ); + + let mut messages = Vec::with_capacity(duties.len()); + + for duty in &duties { + match self + .validator_store + .sign_payload_attestation(duty.pubkey, attestation_data.clone()) + .await + { + Ok(message) => { + messages.push(message); + } + Err(e) => { + crit!( + error = ?e, + validator = ?duty.pubkey, + %slot, + "Failed to sign payload attestation" + ); + } + } + } + + if messages.is_empty() { + return; + } + + let count = messages.len(); + let fork_name = self.chain_spec.fork_name_at_slot::<S::E>(slot); + let result = self + .beacon_nodes + .first_success(|beacon_node| { + let messages = messages.clone(); + async move { + beacon_node + .post_beacon_pool_payload_attestations_ssz(&messages, fork_name) + .await + .map_err(|e| format!("Failed to publish payload attestations (SSZ): {e:?}")) + } + }) + .await; + + let result = match result { + Ok(()) => Ok(()), + Err(_) => { + debug!(%slot, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let messages = messages.clone(); + async move { + beacon_node + .post_beacon_pool_payload_attestations(&messages, fork_name) + .await + .map_err(|e| { + format!("Failed to publish payload attestations (JSON): {e:?}") + }) + } + }) + .await + } + }; + + match result { + Ok(()) => { + info!( + %slot, + %count, + "Successfully published payload attestations" + ); + } + Err(e) => { + crit!( + error = %e, + %slot, + "Failed to publish payload attestations" + ); + } + } + } +} diff --git a/validator_client/validator_services/src/preparation_service.rs b/validator_client/validator_services/src/preparation_service.rs index 063b11512f..913b6ab4e9 100644 --- a/validator_client/validator_services/src/preparation_service.rs +++ b/validator_client/validator_services/src/preparation_service.rs @@ -8,7 +8,7 @@ use std::ops::Deref; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use task_executor::TaskExecutor; -use tokio::time::{Duration, sleep}; +use tokio::time::sleep; use tracing::{debug, error, info, warn}; use types::{ Address, ChainSpec, EthSpec, ProposerPreparationData, SignedValidatorRegistrationData, @@ -174,7 +174,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> PreparationService<S, /// Starts the service which periodically produces proposer preparations. pub fn start_proposer_prepare_service(self, spec: &ChainSpec) -> Result<(), String> { - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); info!("Proposer preparation service started"); let executor = self.executor.clone(); @@ -214,7 +214,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> PreparationService<S, info!("Validator registration service started"); let spec = spec.clone(); - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let executor = self.executor.clone(); diff --git a/validator_client/validator_services/src/sync_committee_service.rs b/validator_client/validator_services/src/sync_committee_service.rs index 28c3d1caad..e34e7636dd 100644 --- a/validator_client/validator_services/src/sync_committee_service.rs +++ b/validator_client/validator_services/src/sync_committee_service.rs @@ -2,8 +2,8 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback}; use bls::PublicKeyBytes; use eth2::types::BlockId; +use futures::StreamExt; use futures::future::FutureExt; -use futures::future::join_all; use logging::crit; use slot_clock::SlotClock; use std::collections::HashMap; @@ -17,7 +17,7 @@ use types::{ ChainSpec, EthSpec, Hash256, Slot, SyncCommitteeSubscription, SyncContributionData, SyncDuty, SyncSelectionProof, SyncSubnetId, }; -use validator_store::{Error as ValidatorStoreError, ValidatorStore}; +use validator_store::{ContributionToSign, SyncMessageToSign, ValidatorStore}; pub const SUBSCRIPTION_LOOKAHEAD_EPOCHS: u64 = 4; @@ -93,7 +93,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S return Ok(()); } - let slot_duration = Duration::from_secs(spec.seconds_per_slot); + let slot_duration = spec.get_slot_duration(); let duration_to_next_slot = self .slot_clock .duration_to_next_slot() @@ -106,18 +106,20 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S let executor = self.executor.clone(); + let sync_message_slot_component = spec.get_sync_message_due(); + let interval_fut = async move { loop { if let Some(duration_to_next_slot) = self.slot_clock.duration_to_next_slot() { // Wait for contribution broadcast interval 1/3 of the way through the slot. - sleep(duration_to_next_slot + slot_duration / 3).await; + sleep(duration_to_next_slot + sync_message_slot_component).await; // Do nothing if the Altair fork has not yet occurred. if !self.altair_fork_activated() { continue; } - if let Err(e) = self.spawn_contribution_tasks(slot_duration).await { + if let Err(e) = self.spawn_contribution_tasks().await { crit!( error = ?e, "Failed to spawn sync contribution tasks" @@ -140,7 +142,8 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S Ok(()) } - async fn spawn_contribution_tasks(&self, slot_duration: Duration) -> Result<(), String> { + async fn spawn_contribution_tasks(&self) -> Result<(), String> { + let spec = &self.duties_service.spec; let slot = self.slot_clock.now().ok_or("Failed to read slot clock")?; let duration_to_next_slot = self .slot_clock @@ -151,7 +154,8 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S // through the slot. This delay triggers at this time let aggregate_production_instant = Instant::now() + duration_to_next_slot - .checked_sub(slot_duration / 3) + .checked_add(spec.get_contribution_message_due()) + .and_then(|offset| offset.checked_sub(spec.get_slot_duration())) .unwrap_or_else(|| Duration::from_secs(0)); let Some(slot_duties) = self @@ -210,7 +214,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S .map(|_| ()) .await } - .instrument(info_span!("sync_committee_signature_publish", %slot)), + .instrument(info_span!("lh_sync_committee_signature_publish", %slot)), "sync_committee_signature_publish", ); @@ -228,7 +232,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S .map(|_| ()) .await } - .instrument(info_span!("sync_committee_aggregate_publish", %slot)), + .instrument(info_span!("lh_sync_committee_aggregate_publish", %slot)), "sync_committee_aggregate_publish", ); @@ -243,78 +247,57 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S beacon_block_root: Hash256, validator_duties: Vec<SyncDuty>, ) -> Result<(), ()> { - // Create futures to produce sync committee signatures. - let signature_futures = validator_duties.iter().map(|duty| async move { - match self - .validator_store - .produce_sync_committee_signature( - slot, - beacon_block_root, - duty.validator_index, - &duty.pubkey, - ) - .await - { - Ok(signature) => Some(signature), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!( - ?pubkey, - validator_index = duty.validator_index, - %slot, - "Missing pubkey for sync committee signature" - ); - None + let messages_to_sign: Vec<_> = validator_duties + .iter() + .map(|duty| SyncMessageToSign { + slot, + beacon_block_root, + validator_index: duty.validator_index, + pubkey: duty.pubkey, + }) + .collect(); + + let signature_stream = self + .validator_store + .sign_sync_committee_signatures(messages_to_sign); + tokio::pin!(signature_stream); + + while let Some(result) = signature_stream.next().await { + match result { + Ok(committee_signatures) if !committee_signatures.is_empty() => { + let committee_signatures = &committee_signatures; + match self + .beacon_nodes + .request(ApiTopic::SyncCommittee, |beacon_node| async move { + beacon_node + .post_beacon_pool_sync_committee_signatures(committee_signatures) + .await + }) + .instrument(info_span!( + "publish_sync_signatures", + count = committee_signatures.len() + )) + .await + { + Ok(()) => info!( + count = committee_signatures.len(), + head_block = ?beacon_block_root, + %slot, + "Successfully published sync committee messages" + ), + Err(e) => error!( + %slot, + error = %e, + "Unable to publish sync committee messages" + ), + } } Err(e) => { - crit!( - validator_index = duty.validator_index, - %slot, - error = ?e, - "Failed to sign sync committee signature" - ); - None + crit!(%slot, error = ?e, "Failed to sign sync committee signatures"); } + _ => {} } - }); - - // Execute all the futures in parallel, collecting any successful results. - let committee_signatures = &join_all(signature_futures) - .instrument(info_span!( - "sign_sync_signatures", - count = validator_duties.len() - )) - .await - .into_iter() - .flatten() - .collect::<Vec<_>>(); - - self.beacon_nodes - .request(ApiTopic::SyncCommittee, |beacon_node| async move { - beacon_node - .post_beacon_pool_sync_committee_signatures(committee_signatures) - .await - }) - .instrument(info_span!( - "publish_sync_signatures", - count = committee_signatures.len() - )) - .await - .map_err(|e| { - error!( - %slot, - error = %e, - "Unable to publish sync committee messages" - ); - })?; - - info!( - count = committee_signatures.len(), - head_block = ?beacon_block_root, - %slot, - "Successfully published sync committee messages" - ); + } Ok(()) } @@ -341,7 +324,7 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S .map(|_| ()) .await } - .instrument(info_span!("publish_sync_committee_aggregate_for_subnet", %slot, ?beacon_block_root, %subnet_id)), + .instrument(info_span!("lh_publish_sync_committee_aggregate_for_subnet", %slot, ?beacon_block_root, %subnet_id)), "sync_committee_aggregate_publish_subnet", ); } @@ -385,77 +368,61 @@ impl<S: ValidatorStore + 'static, T: SlotClock + 'static> SyncCommitteeService<S })? .data; - // Create futures to produce signed contributions. - let aggregator_count = subnet_aggregators.len(); - let signature_futures = subnet_aggregators.into_iter().map( - |(aggregator_index, aggregator_pk, selection_proof)| async move { - match self - .validator_store - .produce_signed_contribution_and_proof( - aggregator_index, - aggregator_pk, - contribution.clone(), - selection_proof, - ) - .await - { - Ok(signed_contribution) => Some(signed_contribution), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!(?pubkey, %slot, "Missing pubkey for sync contribution"); - None - } - Err(e) => { - crit!( + let contributions_to_sign: Vec<_> = subnet_aggregators + .into_iter() + .map( + |(aggregator_index, aggregator_pk, selection_proof)| ContributionToSign { + aggregator_index, + aggregator_pubkey: aggregator_pk, + contribution: contribution.clone(), + selection_proof, + }, + ) + .collect(); + + let contribution_stream = self + .validator_store + .sign_sync_committee_contributions(contributions_to_sign); + tokio::pin!(contribution_stream); + + while let Some(result) = contribution_stream.next().await { + match result { + Ok(signed_contributions) if !signed_contributions.is_empty() => { + let signed_contributions = &signed_contributions; + // Publish to the beacon node. + match self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_contribution_and_proofs(signed_contributions) + .await + }) + .instrument(info_span!( + "publish_sync_contributions", + count = signed_contributions.len() + )) + .await + { + Ok(()) => info!( + subnet = %subnet_id, + beacon_block_root = %beacon_block_root, + num_signers = contribution.aggregation_bits.num_set_bits(), %slot, - error = ?e, - "Unable to sign sync committee contribution" - ); - None + "Successfully published sync contributions" + ), + Err(e) => error!( + %slot, + error = %e, + "Unable to publish signed contributions and proofs" + ), } } - }, - ); - - // Execute all the futures in parallel, collecting any successful results. - let signed_contributions = &join_all(signature_futures) - .instrument(info_span!( - "sign_sync_contributions", - count = aggregator_count - )) - .await - .into_iter() - .flatten() - .collect::<Vec<_>>(); - - // Publish to the beacon node. - self.beacon_nodes - .first_success(|beacon_node| async move { - beacon_node - .post_validator_contribution_and_proofs(signed_contributions) - .await - }) - .instrument(info_span!( - "publish_sync_contributions", - count = signed_contributions.len() - )) - .await - .map_err(|e| { - error!( - %slot, - error = %e, - "Unable to publish signed contributions and proofs" - ); - })?; - - info!( - subnet = %subnet_id, - beacon_block_root = %beacon_block_root, - num_signers = contribution.aggregation_bits.num_set_bits(), - %slot, - "Successfully published sync contributions" - ); + Err(e) => { + crit!(%slot, error = ?e, "Failed to sign sync committee contributions"); + } + _ => {} + } + } Ok(()) } diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 8b1879c837..2c6a68d494 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -7,5 +7,6 @@ authors = ["Sigma Prime <contact@sigmaprime.io>"] [dependencies] bls = { workspace = true } eth2 = { workspace = true } +futures = { workspace = true } slashing_protection = { workspace = true } types = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dbbb3fb839..60e4f03d13 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,13 +1,16 @@ use bls::{PublicKeyBytes, Signature}; use eth2::types::{FullBlockContents, PublishBlockRequest}; +use futures::Stream; use slashing_protection::NotSafe; use std::fmt::Debug; use std::future::Future; use std::sync::Arc; use types::{ - Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, Graffiti, Hash256, - InclusionList, SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, - SignedContributionAndProof, SignedInclusionList, SignedValidatorRegistrationData, Slot, + Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, + ExecutionPayloadEnvelope, Graffiti, Hash256, InclusionList, PayloadAttestationData, + PayloadAttestationMessage, SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, + SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedInclusionList, + SignedValidatorRegistrationData, Slot, SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, }; @@ -20,9 +23,9 @@ pub enum Error<T> { Slashable(NotSafe), SameData, GreaterThanCurrentSlot { slot: Slot, current_slot: Slot }, - GreaterThanCurrentEpoch { epoch: Epoch, current_epoch: Epoch }, UnableToSignAttestation(AttestationError), SpecificError(T), + ExecutorError, Middleware(String), } @@ -32,6 +35,38 @@ impl<T> From<T> for Error<T> { } } +/// Input for batch attestation signing +pub struct AttestationToSign<E: EthSpec> { + pub validator_index: u64, + pub pubkey: PublicKeyBytes, + pub validator_committee_index: usize, + pub attestation: Attestation<E>, +} + +/// Input for batch aggregate signing +pub struct AggregateToSign<E: EthSpec> { + pub pubkey: PublicKeyBytes, + pub aggregator_index: u64, + pub aggregate: Attestation<E>, + pub selection_proof: SelectionProof, +} + +/// Input for batch sync committee message signing +pub struct SyncMessageToSign { + pub slot: Slot, + pub beacon_block_root: Hash256, + pub validator_index: u64, + pub pubkey: PublicKeyBytes, +} + +/// Input for batch sync committee contribution signing +pub struct ContributionToSign<E: EthSpec> { + pub aggregator_index: u64, + pub aggregator_pubkey: PublicKeyBytes, + pub contribution: SyncCommitteeContribution<E>, + pub selection_proof: SyncSelectionProof, +} + /// A helper struct, used for passing data from the validator store to services. pub struct ProposalData { pub validator_index: Option<u64>, @@ -104,31 +139,26 @@ pub trait ValidatorStore: Send + Sync { current_slot: Slot, ) -> impl Future<Output = Result<SignedBlock<Self::E>, Error<Self::Error>>> + Send; - fn sign_attestation( - &self, - validator_pubkey: PublicKeyBytes, - validator_committee_position: usize, - attestation: &mut Attestation<Self::E>, - current_epoch: Epoch, - ) -> impl Future<Output = Result<(), Error<Self::Error>>> + Send; + /// Sign a batch of `attestations` and apply slashing protection to them. + /// + /// Returns a stream of batches of successfully signed attestations. Each batch contains + /// attestations that passed slashing protection, along with the validator index of the signer. + /// Eventually this will be replaced by `SingleAttestation` use. + /// + /// Output: + /// + /// * Vec of (validator_index, signed_attestation). + #[allow(clippy::type_complexity)] + fn sign_attestations( + self: &Arc<Self>, + attestations: Vec<AttestationToSign<Self::E>>, + ) -> impl Stream<Item = Result<Vec<(u64, Attestation<Self::E>)>, Error<Self::Error>>> + Send; fn sign_validator_registration_data( &self, validator_registration_data: ValidatorRegistrationData, ) -> impl Future<Output = Result<SignedValidatorRegistrationData, Error<Self::Error>>> + Send; - /// Signs an `AggregateAndProof` for a given validator. - /// - /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be - /// modified by actors other than the signing validator. - fn produce_signed_aggregate_and_proof( - &self, - validator_pubkey: PublicKeyBytes, - aggregator_index: u64, - aggregate: Attestation<Self::E>, - selection_proof: SelectionProof, - ) -> impl Future<Output = Result<SignedAggregateAndProof<Self::E>, Error<Self::Error>>> + Send; - /// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to /// `validator_pubkey`. fn produce_selection_proof( @@ -145,21 +175,23 @@ pub trait ValidatorStore: Send + Sync { subnet_id: SyncSubnetId, ) -> impl Future<Output = Result<SyncSelectionProof, Error<Self::Error>>> + Send; - fn produce_sync_committee_signature( - &self, - slot: Slot, - beacon_block_root: Hash256, - validator_index: u64, - validator_pubkey: &PublicKeyBytes, - ) -> impl Future<Output = Result<SyncCommitteeMessage, Error<Self::Error>>> + Send; + /// Sign a batch of aggregate and proofs and return results as a stream of batches. + fn sign_aggregate_and_proofs( + self: &Arc<Self>, + aggregates: Vec<AggregateToSign<Self::E>>, + ) -> impl Stream<Item = Result<Vec<SignedAggregateAndProof<Self::E>>, Error<Self::Error>>> + Send; - fn produce_signed_contribution_and_proof( - &self, - aggregator_index: u64, - aggregator_pubkey: PublicKeyBytes, - contribution: SyncCommitteeContribution<Self::E>, - selection_proof: SyncSelectionProof, - ) -> impl Future<Output = Result<SignedContributionAndProof<Self::E>, Error<Self::Error>>> + Send; + /// Sign a batch of sync committee messages and return results as a stream of batches. + fn sign_sync_committee_signatures( + self: &Arc<Self>, + messages: Vec<SyncMessageToSign>, + ) -> impl Stream<Item = Result<Vec<SyncCommitteeMessage>, Error<Self::Error>>> + Send; + + /// Sign a batch of sync committee contributions and return results as a stream of batches. + fn sign_sync_committee_contributions( + self: &Arc<Self>, + contributions: Vec<ContributionToSign<Self::E>>, + ) -> impl Stream<Item = Result<Vec<SignedContributionAndProof<Self::E>>, Error<Self::Error>>> + Send; fn sign_inclusion_list( &self, @@ -174,6 +206,20 @@ pub trait ValidatorStore: Send + Sync { /// runs. fn prune_slashing_protection_db(&self, current_epoch: Epoch, first_run: bool); + /// Sign an `ExecutionPayloadEnvelope` for Gloas. + fn sign_execution_payload_envelope( + &self, + validator_pubkey: PublicKeyBytes, + envelope: ExecutionPayloadEnvelope<Self::E>, + ) -> impl Future<Output = Result<SignedExecutionPayloadEnvelope<Self::E>, Error<Self::Error>>> + Send; + + /// Sign a `PayloadAttestationData` for the PTC. + fn sign_payload_attestation( + &self, + validator_pubkey: PublicKeyBytes, + data: PayloadAttestationData, + ) -> impl Future<Output = Result<PayloadAttestationMessage, Error<Self::Error>>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. diff --git a/validator_manager/Cargo.toml b/validator_manager/Cargo.toml index 16ce1e023f..7dabd5445c 100644 --- a/validator_manager/Cargo.toml +++ b/validator_manager/Cargo.toml @@ -11,7 +11,7 @@ clap = { workspace = true } clap_utils = { workspace = true } educe = { workspace = true } environment = { workspace = true } -eth2 = { workspace = true } +eth2 = { workspace = true, features = ["lighthouse"] } eth2_network_config = { workspace = true } eth2_wallet = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -29,4 +29,4 @@ beacon_chain = { workspace = true } http_api = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } -validator_http_api = { workspace = true } +validator_http_api = { workspace = true, features = ["testing"] } diff --git a/validator_manager/src/exit_validators.rs b/validator_manager/src/exit_validators.rs index b53d9c0a16..00bcb36e80 100644 --- a/validator_manager/src/exit_validators.rs +++ b/validator_manager/src/exit_validators.rs @@ -280,7 +280,7 @@ pub fn get_current_epoch<E: EthSpec>(genesis_time: u64, spec: &ChainSpec) -> Opt let slot_clock = SystemTimeSlotClock::new( spec.genesis_slot, Duration::from_secs(genesis_time), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); slot_clock.now().map(|s| s.epoch(E::slots_per_epoch())) } @@ -322,7 +322,7 @@ mod test { let mut spec = ChainSpec::mainnet(); spec.shard_committee_period = 1; spec.altair_fork_epoch = Some(Epoch::new(0)); - spec.bellatrix_fork_epoch = Some(Epoch::new(1)); + spec.bellatrix_fork_epoch = Some(Epoch::new(0)); spec.capella_fork_epoch = Some(Epoch::new(2)); spec.deneb_fork_epoch = Some(Epoch::new(3)); @@ -330,15 +330,8 @@ mod test { let harness = &beacon_node.harness; let mock_el = harness.mock_execution_layer.as_ref().unwrap(); - let execution_ctx = mock_el.server.ctx.clone(); - // Move to terminal block. mock_el.server.all_payloads_valid(); - execution_ctx - .execution_block_generator - .write() - .move_to_terminal_block() - .unwrap(); Self { exit_config: None, diff --git a/validator_manager/src/import_validators.rs b/validator_manager/src/import_validators.rs index 24917f7d1b..0d6d358edb 100644 --- a/validator_manager/src/import_validators.rs +++ b/validator_manager/src/import_validators.rs @@ -112,8 +112,7 @@ pub fn cli_app() -> Command { .value_name("ETH1_ADDRESS") .help("When provided, the imported validator will use the suggested fee recipient. Omit this flag to use the default value from the VC.") .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(GAS_LIMIT) @@ -122,8 +121,7 @@ pub fn cli_app() -> Command { .help("When provided, the imported validator will use this gas limit. It is recommended \ to leave this as the default value by not specifying this flag.",) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(BUILDER_PROPOSALS) @@ -132,8 +130,7 @@ pub fn cli_app() -> Command { blocks via builder rather than the local EL.",) .value_parser(["true","false"]) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(BUILDER_BOOST_FACTOR) @@ -144,8 +141,7 @@ pub fn cli_app() -> Command { when choosing between a builder payload header and payload from \ the local execution node.",) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(PREFER_BUILDER_PROPOSALS) @@ -154,8 +150,16 @@ pub fn cli_app() -> Command { constructed by builders, regardless of payload value.",) .value_parser(["true","false"]) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), + ) + .arg( + Arg::new(ENABLED) + .long(ENABLED) + .help("When provided, the imported validator will be \ + enabled or disabled.",) + .value_parser(["true","false"]) + .action(ArgAction::Set) + .display_order(0), ) } @@ -225,48 +229,113 @@ async fn run(config: ImportConfig) -> Result<(), String> { enabled, } = config; - let validators: Vec<ValidatorSpecification> = - if let Some(validators_format_path) = &validators_file_path { - if !validators_format_path.exists() { - return Err(format!( - "Unable to find file at {:?}", - validators_format_path - )); - } + let validators: Vec<ValidatorSpecification> = if let Some(validators_format_path) = + &validators_file_path + { + if !validators_format_path.exists() { + return Err(format!( + "Unable to find file at {:?}", + validators_format_path + )); + } - let validators_file = fs::OpenOptions::new() - .read(true) - .create(false) - .open(validators_format_path) - .map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?; + let validators_file = fs::OpenOptions::new() + .read(true) + .create(false) + .open(validators_format_path) + .map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?; - serde_json::from_reader(&validators_file).map_err(|e| { + // Define validators as mutable so that if a relevant flag is supplied, the fields can be overridden. + let mut validators: Vec<ValidatorSpecification> = serde_json::from_reader(&validators_file) + .map_err(|e| { format!( "Unable to parse JSON in {:?}: {:?}", validators_format_path, e ) - })? - } else if let Some(keystore_format_path) = &keystore_file_path { - vec![ValidatorSpecification { - voting_keystore: KeystoreJsonStr( - Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?, - ), - voting_keystore_password: password.ok_or_else(|| { - "The --password flag is required to supply the keystore password".to_string() - })?, - slashing_protection: None, - fee_recipient, - gas_limit, - builder_proposals, - builder_boost_factor, - prefer_builder_proposals, - enabled, - }] - } else { - return Err(format!( - "One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required." - )); - }; + })?; + + // Log the overridden note when one or more flags is supplied + if let Some(override_fee_recipient) = fee_recipient { + eprintln!( + "Please note! --suggested-fee-recipient is provided. This will override existing fee recipient defined in validators.json with: {:?}", + override_fee_recipient + ); + } + if let Some(override_gas_limit) = gas_limit { + eprintln!( + "Please note! --gas-limit is provided. This will override existing gas limit defined in validators.json with: {}", + override_gas_limit + ); + } + if let Some(override_builder_proposals) = builder_proposals { + eprintln!( + "Please note! --builder-proposals is provided. This will override existing builder proposal setting defined in validators.json with: {}", + override_builder_proposals + ); + } + if let Some(override_builder_boost_factor) = builder_boost_factor { + eprintln!( + "Please note! --builder-boost-factor is provided. This will override existing builder boost factor defined in validators.json with: {}", + override_builder_boost_factor + ); + } + if let Some(override_prefer_builder_proposals) = prefer_builder_proposals { + eprintln!( + "Please note! --prefer-builder-proposals is provided. This will override existing prefer builder proposal setting defined in validators.json with: {}", + override_prefer_builder_proposals + ); + } + if let Some(override_enabled) = enabled { + eprintln!( + "Please note! --enabled flag is provided. This will override existing setting defined in validators.json with: {}", + override_enabled + ); + } + + // Override the fields in validators.json file if the flag is supplied + for validator in &mut validators { + if let Some(override_fee_recipient) = fee_recipient { + validator.fee_recipient = Some(override_fee_recipient); + } + if let Some(override_gas_limit) = gas_limit { + validator.gas_limit = Some(override_gas_limit); + } + if let Some(override_builder_proposals) = builder_proposals { + validator.builder_proposals = Some(override_builder_proposals); + } + if let Some(override_builder_boost_factor) = builder_boost_factor { + validator.builder_boost_factor = Some(override_builder_boost_factor); + } + if let Some(override_prefer_builder_proposals) = prefer_builder_proposals { + validator.prefer_builder_proposals = Some(override_prefer_builder_proposals); + } + if let Some(override_enabled) = enabled { + validator.enabled = Some(override_enabled); + } + } + + validators + } else if let Some(keystore_format_path) = &keystore_file_path { + vec![ValidatorSpecification { + voting_keystore: KeystoreJsonStr( + Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?, + ), + voting_keystore_password: password.ok_or_else(|| { + "The --password flag is required to supply the keystore password".to_string() + })?, + slashing_protection: None, + fee_recipient, + gas_limit, + builder_proposals, + builder_boost_factor, + prefer_builder_proposals, + enabled, + }] + } else { + return Err(format!( + "One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required." + )); + }; let count = validators.len(); diff --git a/validator_manager/src/list_validators.rs b/validator_manager/src/list_validators.rs index f7a09f8d8e..7cc31d1b7a 100644 --- a/validator_manager/src/list_validators.rs +++ b/validator_manager/src/list_validators.rs @@ -185,7 +185,9 @@ async fn run<E: EthSpec>(config: ListConfig) -> Result<Vec<SingleKeystoreRespons eprintln!("Please keep your validator running till exit epoch"); eprintln!( "Exit epoch in approximately {} secs", - (exit_epoch - current_epoch) * spec.seconds_per_slot * E::slots_per_epoch() + (exit_epoch - current_epoch) + * spec.get_slot_duration().as_secs() + * E::slots_per_epoch() ); } ValidatorStatus::ExitedSlashed | ValidatorStatus::ExitedUnslashed => { diff --git a/wordlist.txt b/wordlist.txt index e0e1fe7d73..822e336146 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -58,6 +58,7 @@ JSON KeyManager Kurtosis LMDB +LLM LLVM LRU LTO