Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions beacon-chain/graffiti/graffiti-proposal-brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
# Proposal: Automatic Client Version Graffiti (Brief)

## TL;DR

Add automatic EL+CL version info to block graffiti following [ethereum/execution-apis#517](https://github.com/ethereum/execution-apis/pull/517). Format: `GE168dPR63af` (Geth + Prysm). User graffiti always takes precedence.

## Problem

**Prysm hasn't yet implemented the ecosystem standard.** Prysm currently leaves graffiti empty (when no user graffiti is configured), providing no on-chain visibility into client versions.

**Why it matters**: On-chain visibility of client diversity is much easier with standardized graffiti.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: "Why it matters" is a common AI verbal tick that I would not like to see propagating through our design docs 🙏


## Proposed Solution

### Format
```
<EL_CODE><EL_COMMIT><CL_CODE><CL_COMMIT>
GE 168d PR 63af = "GE168dPR63af"
```

- **EL_CODE**: 2-letter code (GE=Geth, NM=Nethermind, BU=Besu, RH=Reth, EG=Erigon)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can link to the execution spec where the abbreviations are defined: src/engine/identification.md.

- **EL_COMMIT**: First 4 hex chars of execution client's git commit (from `engine_getClientVersionV1` JSON-RPC response)
- **CL_CODE**: PR (Prysm)
- **CL_COMMIT**: First 4 hex chars of Prysm's git commit (from build-time ldflags via `version.GetCommitPrefix()`)

### Priority Order
```
1. User graffiti (VC flag, proposer settings, graffiti file) ← Always wins
2. Auto-generated "GE168dPR63af" ← New feature
3. Default "Prysm/v6.1.0" ← Fallback
```

**Key principle**: User control preserved. No opt-out flag needed.

### Examples

| Configuration | Result |
|--------------|------------------------------------|
| No graffiti | `GE168dPR63af` ✨ **NEW** |
| `--graffiti "🚀"` | `🚀` (unchanged) |
| Proposer settings | Custom graffiti (unchanged) |
| Old Geth (no API) | `Prysm/v6.1.0` (graceful fallback) |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we follow the "flexible standard" that was proposed alongside the engine API PR? These examples imply that we would only set the custom graffiti if no user graffiti is specified.

If we do that it could also be nice to emit a log to warn the user that they are using up valuable graffiti real estate that could be used for client diversity. For example look at this block: Twinstake for EtherFi. Maybe they would be happy to just set the flag to EtherFi if they knew it helped client diversity analysis. As an aside, it's a little pointless for the big stakers to set custom graffiti since the chain explorers know who they are anyway.


## Key Concepts

### Where Commit Hashes Come From
The graffiti combines version info from **two different sources**:

| Component | Source | How Retrieved |
|-----------|--------|---------------|
| **EL Code + Commit** | Execution client (Geth/etc) | Runtime JSON-RPC call: `engine_getClientVersionV1` returns `{"code":"GE", "commit":"0x168d..."}` |
| **CL Code + Commit** | Prysm binary | Build-time: Bazel injects git commit via ldflags → `version.GetCommitPrefix()` returns "63af" |

### Component Relationships
```
Blockchain Service (contains and delegates)
├─► EngineVersionCache (caches EL version, TTL = 6 epochs)
└─► Graffiti Service (resolution logic)
├─► Uses: EngineClient (makes JSON-RPC calls)
└─► Uses: EngineVersionCache (avoids repeated calls)

Pattern: Blockchain Service implements GraffitiResolver interface,
delegates actual work to Graffiti Service
```

## Architecture Overview

### Component Flow (Simple)

```
┌─────────────────┐
│ Validator Client│
│ (unchanged) │
└────────┬────────┘
│ gRPC: GetBeaconBlock(graffiti="")
│ "I need a block to sign"
┌──────────────────────────────────────┐
│ Beacon Node │
│ │
│ 1. RPC Handler │
│ → Receives VC graffiti │
│ │
│ 2. Graffiti Service [NEW] │
│ → Resolves priority order │
│ (VC > auto > default) │
│ │
│ 3. Engine Version Cache [NEW] │
│ → Stores EL version info │
│ (TTL: 6 epochs, ~38 min) │
│ │
│ 4. Background Refresh [NEW] │
│ → Pre-warms cache │
│ (Every 2 epochs, ~13 min) │
│ │
│ 5. Engine Client Extension [NEW] │
│ → engine_getClientVersionV1 │
└──────────────┬───────────────────────┘
│ JSON-RPC
┌────────────────┐
│ Geth/Nethermind│
└────────────────┘
```

**How it works**:
1. **Validator Client** calls beacon node to get a block (existing behavior, unchanged)
2. **Beacon Node RPC** receives the request with VC's graffiti (empty if not set)
3. **Graffiti Service** decides which graffiti to use based on priority
4. **Cache** provides pre-fetched EL version (if needed for auto-generation)
5. **Block returned** to VC with resolved graffiti
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I think we should try to make design docs lean where we can. I feel like a block diagram and in particular including the validators role here is overkill or maybe just redundant with general knowledge of how block proposal works?


### New Components

| Component | One-Line Summary |
|-----------|-----------------|
| **GraffitiResolver Interface** | Interface that defines `ResolveGraffiti(vcGraffiti)` method for decoupling RPC layer from implementation |
| **Graffiti Service** | Resolves graffiti based on priority: VC graffiti > auto-generated > default |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this needs to be it's own service. It could be a runloop inside beacon-chain/execution/service.go.

| **Engine Version Cache** | Thread-safe cache storing EL version with 6-epoch TTL to avoid RPC latency |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do not need a TTL. Just update the value whenever you can and use the last good value. If the call has never succeeded then the EL abbreviation will be blank. When you generate the client string you can check if the EL abbreviation is set, and if it's not, generate the graffiti with the CL abbrev + commit only.

| **Background Refresh** | Goroutine that pre-warms cache every 2 epochs so block production never waits |
| **Engine Client Extension** | Adds `GetClientVersion()` RPC method calling `engine_getClientVersionV1` |
| **Version Helpers** | Utility functions to extract/normalize commit hashes without runtime git calls |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would there be runtime git calls? As the doc states above, this information is baked into the binary via version.GetCommitPrefix().


### Edited Components

| Component | What Changes | Why |
|-----------|---------------------------------------------------------------------------------------------------------------|-----|
| **Blockchain Service** | Implement GraffitiResolver interface, initialize cache + graffiti service on startup, spawn refresh goroutine | Central place to manage lifecycle of new components and provide resolution logic |
| **RPC Validator Server** | Add `GraffitiResolver` field, call it (ResolveGraffiti()) in `GetBeaconBlock()` | Integration point where VC graffiti meets BN resolution logic |
| **Engine Client** | Add `GetClientVersion()` interface method and implementation | Extends existing engine API client with new standardized method |
| **Config Params** | Add cache TTL variables (6 epochs, 2 epochs) computed at init | Configuration for cache timing based on beacon chain slot duration |
| **Node Initialization** | Wire graffiti resolver through RPC config | Connects blockchain service to RPC layer via dependency injection |

**Critical Design Choices**:
- ✅ **All logic on beacon node** (VC unchanged, correct process boundary)
- ✅ **Cache with background refresh** (zero latency on block production - pre-warms every 2 epochs)
- ✅ **No runtime git operations** (works in containers - Prysm commit hash injected via Bazel ldflags at compile time)
- ✅ **Best-effort Engine API** (5s timeout, graceful fallback to "Prysm/vX.X.X" if EL doesn't support `engine_getClientVersionV1`)
- ✅ **Interface-based delegation** (RPC depends on `blockchain.GraffitiResolver` interface, not concrete implementation)

### Component Flow (Detailed)

Shows all components (new + edited) and their interactions:

```
┌───────────────────────────────────────────────────────────────┐
│ VALIDATOR CLIENT │
│ (unchanged) │
└────────────────────────┬──────────────────────────────────────┘
│ gRPC: GetBeaconBlock(graffiti="")
┌───────────────────────────────────────────────────────────────┐
│ BEACON NODE │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Node Initialization [EDITED] │ │
│ │ - Wires GraffitiResolver through RPC config │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ RPC Validator Server [EDITED] │ │
│ │ - GetBeaconBlock() receives VC graffiti │ │
│ │ - Calls: graffitiResolver.ResolveGraffiti() │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Blockchain Service [EDITED] │ │
│ │ - Implements GraffitiResolver interface │ │
│ │ - Delegates to graffitiService │ │
│ │ - Manages lifecycle (cache + service + refresh)│ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Graffiti Service [NEW] │ │
│ │ - Priority check: │ │
│ │ 1. VC graffiti provided? → Use it │ │
│ │ 2. Can auto-generate? → generateAutoGraffiti()│ │
│ │ 3. Else → defaultGraffiti() │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ (if auto-generating) │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Engine Version Cache [NEW] │ │
│ │ - Get(client, maxAge=6epochs) │ │
│ │ - Cache hit? → Return cached data │ │
│ │ - Cache miss? → Call client.GetClientVersion() │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ ↑ │
│ (if cache miss) │ │ │
│ ↓ │ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Engine Client [EDITED] │ │
│ │ - GetClientVersion() calls: │ │
│ │ engine_getClientVersionV1 (5s timeout) │ │
│ │ - Returns: [{code:"GE", commit:"0x168d..."}] │ │
│ └──────────────────────┬──────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Version Helpers [NEW] │ │
│ │ - GetCommitPrefix() → "63af" │ │
│ │ - NormalizeCommitHash("0x168d...") → "168d" │ │
│ │ - NormalizeClientCode("GE") → "GE" │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Background Refresh Goroutine [NEW] │ ┐ │
│ │ - Ticker: Every 2 epochs (~13 min) │ │ │
│ │ - Calls: cache.Get() to pre-warm │ │ Async │
│ │ - Ensures cache always fresh for block prod │ │ Refresh│
│ └─────────────────────────────────────────────────┘ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Config Params [EDITED] │ │
│ │ - EngineVersionCacheMaxAge = 6 epochs │ │
│ │ - EngineVersionRefreshInterval = 2 epochs │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────┬─────────────────────────────────────┘
│ JSON-RPC
│ engine_getClientVersionV1
┌────────────────┐
│ Execution │ ← Background refresh periodically
│ Client (Geth) │ fetches version via same path
└────────────────┘
Returns: {"code":"GE", "commit":"0x168d..."}
```

**Legend**:
- `[NEW]` - New component being created
- `[EDITED]` - Existing component with modifications
- `─ ─ ─ ─` - Background refresh path (async, non-blocking)
- `────────` - Request path (synchronous)

**Files to be Changed**:
```
NEW:
- beacon-chain/execution/types/execution_data.go
- beacon-chain/execution/engine_version_cache.go
- beacon-chain/graffiti/graffiti_service.go
- runtime/version/version.go (helpers)
- Tests + metrics

MODIFY:
- beacon-chain/blockchain/service.go (init cache + service)
- beacon-chain/execution/engine_client.go (new RPC method)
- beacon-chain/rpc/.../proposer.go (call resolver)
- config/params/config.go (cache TTL)
```

## Open Questions

1. **Cache timing**: Uses mainnet values (6 epochs, 2 epochs) for all networks. Add env var overrides for testing?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mainnet values (6 epochs, 2 epochs) doesn't track for me, what does that mean?

This API call should be incredibly cheap for the EL to serve. I think you can do it once at startup, and then a couple times an epoch to be on the safe side. Idea to avoid any epoch boundaries: slot % 8 == 4 means you'd do it 4 times an epoch, never too close to the beginning or end of an epoch.

2. **Missing EL commit**: Use "0000" placeholder or fall back to default?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK what "fall back to default" would mean? zero value makes sense.

3 changes: 3 additions & 0 deletions changelog/satushh-graffiti.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- Proposal design document to implement graffiti. Currently it is empty by default and the idea is to have it of the form GE168dPR63af
Loading