graffiti initial implementation that works locally on kurtosis

This commit is contained in:
satushh
2025-12-03 14:44:30 +00:00
parent 78235a4d91
commit 6cb3677541
12 changed files with 450 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ go_library(
"deposit.go",
"engine_client.go",
"errors.go",
"graffiti_info.go",
"log.go",
"log_processing.go",
"metrics.go",
@@ -88,6 +89,7 @@ go_test(
"engine_client_fuzz_test.go",
"engine_client_test.go",
"execution_chain_test.go",
"graffiti_info_test.go",
"init_test.go",
"log_processing_test.go",
"mock_test.go",

View File

@@ -60,7 +60,17 @@ var (
}
)
// ClientVersionV1 represents the response from engine_getClientVersionV1.
type ClientVersionV1 struct {
Code string `json:"code"`
Name string `json:"name"`
Version string `json:"version"`
Commit string `json:"commit"`
}
const (
// GetClientVersionMethod is the engine_getClientVersionV1 method for JSON-RPC.
GetClientVersionMethod = "engine_getClientVersionV1"
// NewPayloadMethod v1 request string for JSON-RPC.
NewPayloadMethod = "engine_newPayloadV1"
// NewPayloadMethodV2 v2 request string for JSON-RPC.
@@ -349,6 +359,27 @@ func (s *Service) ExchangeCapabilities(ctx context.Context) ([]string, error) {
return elSupportedEndpointsSlice, nil
}
// GetClientVersion calls engine_getClientVersionV1 to retrieve EL client information.
func (s *Service) GetClientVersion(ctx context.Context) ([]ClientVersionV1, error) {
ctx, span := trace.StartSpan(ctx, "powchain.engine-api-client.GetClientVersion")
defer span.End()
// Per spec, we send our own client info as the parameter
clVersion := ClientVersionV1{
Code: CLCode,
Name: "Prysm",
Version: version.SemanticVersion(),
Commit: version.GetCommitPrefix(),
}
var result []ClientVersionV1
err := s.rpcClient.CallContext(ctx, &result, GetClientVersionMethod, clVersion)
if err != nil {
return nil, handleRPCError(err)
}
return result, nil
}
// GetTerminalBlockHash returns the valid terminal block hash based on total difficulty.
//
// Spec code:

View File

@@ -0,0 +1,160 @@
package execution
import (
"sync"
"github.com/OffchainLabs/prysm/v7/runtime/version"
)
const (
// CLCode is the two-letter client code for Prysm.
CLCode = "PR"
)
// GraffitiInfo holds version information for generating block graffiti.
// It is thread-safe and can be updated by the execution service and read by the validator server.
type GraffitiInfo struct {
mu sync.RWMutex
userGraffiti string // From --graffiti flag (set once at startup)
elCode string // From engine_getClientVersionV1
elCommit string // From engine_getClientVersionV1
}
// NewGraffitiInfo creates a new GraffitiInfo with the given user graffiti.
func NewGraffitiInfo(userGraffiti string) *GraffitiInfo {
return &GraffitiInfo{
userGraffiti: userGraffiti,
}
}
// UpdateFromEngine updates the EL client information.
func (g *GraffitiInfo) UpdateFromEngine(code, commit string) {
g.mu.Lock()
defer g.mu.Unlock()
g.elCode = code
g.elCommit = commit
}
// GenerateGraffiti generates graffiti using the flexible standard.
// It packs as much client info as space allows after user graffiti.
//
// Available Space | Format
// ≥12 bytes | EL(2)+commit(4)+CL(2)+commit(4)+user
// 8-11 bytes | EL(2)+commit(2)+CL(2)+commit(2)+user
// 4-7 bytes | EL(2)+CL(2)+user
// 2-3 bytes | EL(2)+user
// <2 bytes | user only
func (g *GraffitiInfo) GenerateGraffiti() [32]byte {
g.mu.RLock()
defer g.mu.RUnlock()
var result [32]byte
userLen := len(g.userGraffiti)
available := 32 - userLen
// If no EL info available, use user graffiti or fallback
if g.elCode == "" {
if g.userGraffiti != "" {
copy(result[:], g.userGraffiti)
return result
}
// Fallback: "Prysm/vX.Y.Z"
fallback := "Prysm/" + version.SemanticVersion()
copy(result[:], fallback)
return result
}
clCommit := version.GetCommitPrefix()
elCommit4 := truncateCommit(g.elCommit, 4)
elCommit2 := truncateCommit(g.elCommit, 2)
clCommit4 := truncateCommit(clCommit, 4)
clCommit2 := truncateCommit(clCommit, 2)
var graffiti string
switch {
case available >= 12:
// Full: EL(2)+commit(4)+CL(2)+commit(4)+user
graffiti = g.elCode + elCommit4 + CLCode + clCommit4 + g.userGraffiti
case available >= 8:
// Reduced commits: EL(2)+commit(2)+CL(2)+commit(2)+user
graffiti = g.elCode + elCommit2 + CLCode + clCommit2 + g.userGraffiti
case available >= 4:
// Codes only: EL(2)+CL(2)+user
graffiti = g.elCode + CLCode + g.userGraffiti
case available >= 2:
// EL code only: EL(2)+user
graffiti = g.elCode + g.userGraffiti
default:
// User graffiti only
graffiti = g.userGraffiti
}
copy(result[:], graffiti)
return result
}
// truncateCommit returns the first n characters of the commit string.
func truncateCommit(commit string, n int) string {
if len(commit) <= n {
return commit
}
return commit[:n]
}
// GenerateGraffitiWithUserInput generates graffiti using the flexible standard
// with the provided user graffiti from the validator client request.
// This is used when the validator client sends custom graffiti per block.
func (g *GraffitiInfo) GenerateGraffitiWithUserInput(userGraffiti []byte) [32]byte {
g.mu.RLock()
defer g.mu.RUnlock()
var result [32]byte
userStr := string(userGraffiti)
// Trim trailing null bytes
for len(userStr) > 0 && userStr[len(userStr)-1] == 0 {
userStr = userStr[:len(userStr)-1]
}
userLen := len(userStr)
available := 32 - userLen
// If no EL info available, use user graffiti or fallback
if g.elCode == "" {
if userLen > 0 {
copy(result[:], userStr)
return result
}
// Fallback: "Prysm/vX.Y.Z"
fallback := "Prysm/" + version.SemanticVersion()
copy(result[:], fallback)
return result
}
clCommit := version.GetCommitPrefix()
elCommit4 := truncateCommit(g.elCommit, 4)
elCommit2 := truncateCommit(g.elCommit, 2)
clCommit4 := truncateCommit(clCommit, 4)
clCommit2 := truncateCommit(clCommit, 2)
var graffiti string
switch {
case available >= 12:
// Full: EL(2)+commit(4)+CL(2)+commit(4)+user
graffiti = g.elCode + elCommit4 + CLCode + clCommit4 + userStr
case available >= 8:
// Reduced commits: EL(2)+commit(2)+CL(2)+commit(2)+user
graffiti = g.elCode + elCommit2 + CLCode + clCommit2 + userStr
case available >= 4:
// Codes only: EL(2)+CL(2)+user
graffiti = g.elCode + CLCode + userStr
case available >= 2:
// EL code only: EL(2)+user
graffiti = g.elCode + userStr
default:
// User graffiti only
graffiti = userStr
}
copy(result[:], graffiti)
return result
}

View File

@@ -0,0 +1,185 @@
package execution
import (
"testing"
"github.com/OffchainLabs/prysm/v7/testing/require"
)
func TestGraffitiInfo_GenerateGraffiti_NoELInfo(t *testing.T) {
g := NewGraffitiInfo("")
// Without EL info, should return fallback
result := g.GenerateGraffiti()
resultStr := string(result[:])
// Should start with "Prysm/"
require.Equal(t, true, len(resultStr) > 0 && resultStr[:6] == "Prysm/", "Expected fallback graffiti to start with Prysm/")
}
func TestGraffitiInfo_GenerateGraffiti_WithUserGraffiti(t *testing.T) {
g := NewGraffitiInfo("my validator")
// Without EL info, should return user graffiti
result := g.GenerateGraffiti()
resultStr := trimNullBytes(string(result[:]))
require.Equal(t, "my validator", resultStr)
}
func TestGraffitiInfo_GenerateGraffiti_FlexibleStandard(t *testing.T) {
tests := []struct {
name string
userGraffiti string
elCode string
elCommit string
wantPrefix string
}{
{
name: "Full format - empty user graffiti",
userGraffiti: "",
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "GEabcdPR", // GE + 4 char commit + PR + 4 char CL commit
},
{
name: "Full format - short user graffiti",
userGraffiti: "Bob",
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "GEabcdPR",
},
{
name: "Reduced format - 20 char user graffiti",
userGraffiti: "12345678901234567890", // 20 chars, 12 bytes available
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "GEabcdPR", // Still fits full format
},
{
name: "Reduced commits - 24 char user graffiti",
userGraffiti: "123456789012345678901234", // 24 chars, 8 bytes available
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "GEabPR", // EL(2)+commit(2)+CL(2)+commit(2), CL commit is dynamic
},
{
name: "EL+CL codes only - 28 char user graffiti",
userGraffiti: "1234567890123456789012345678", // 28 chars, 4 bytes available
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "GEPR", // EL(2)+CL(2)
},
{
name: "EL code only - 30 char user graffiti",
userGraffiti: "123456789012345678901234567890", // 30 chars, 2 bytes available
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "GE", // EL(2) only
},
{
name: "User only - 32 char user graffiti",
userGraffiti: "12345678901234567890123456789012", // 32 chars, 0 bytes available
elCode: "GE",
elCommit: "abcd1234",
wantPrefix: "12345678901234567890123456789012",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGraffitiInfo(tt.userGraffiti)
g.UpdateFromEngine(tt.elCode, tt.elCommit)
result := g.GenerateGraffiti()
resultStr := string(result[:])
// Check that result starts with expected prefix
require.Equal(t, true, len(resultStr) >= len(tt.wantPrefix), "Result too short")
require.Equal(t, tt.wantPrefix, resultStr[:len(tt.wantPrefix)], "Prefix mismatch")
})
}
}
func TestGraffitiInfo_GenerateGraffitiWithUserInput(t *testing.T) {
g := NewGraffitiInfo("")
g.UpdateFromEngine("GE", "abcd1234")
tests := []struct {
name string
userInput []byte
wantPrefix string
}{
{
name: "Empty input",
userInput: []byte{},
wantPrefix: "GEabcdPR",
},
{
name: "Short input",
userInput: []byte("hello"),
wantPrefix: "GEabcdPR",
},
{
name: "Input with null bytes",
userInput: append([]byte("test"), 0, 0, 0),
wantPrefix: "GEabcdPR",
},
{
name: "Full 32 byte input",
userInput: []byte("12345678901234567890123456789012"),
wantPrefix: "12345678901234567890123456789012",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := g.GenerateGraffitiWithUserInput(tt.userInput)
resultStr := string(result[:])
require.Equal(t, true, len(resultStr) >= len(tt.wantPrefix), "Result too short")
require.Equal(t, tt.wantPrefix, resultStr[:len(tt.wantPrefix)], "Prefix mismatch")
})
}
}
func TestGraffitiInfo_UpdateFromEngine(t *testing.T) {
g := NewGraffitiInfo("")
// Initially no EL info
result := g.GenerateGraffiti()
resultStr := string(result[:])
require.Equal(t, true, resultStr[:6] == "Prysm/", "Expected fallback before update")
// Update with EL info
g.UpdateFromEngine("GE", "1234abcd")
result = g.GenerateGraffiti()
resultStr = string(result[:])
require.Equal(t, "GE1234PR", resultStr[:8], "Expected EL info after update")
}
func TestTruncateCommit(t *testing.T) {
tests := []struct {
commit string
n int
want string
}{
{"abcd1234", 4, "abcd"},
{"ab", 4, "ab"},
{"", 4, ""},
{"abcdef", 2, "ab"},
}
for _, tt := range tests {
got := truncateCommit(tt.commit, tt.n)
require.Equal(t, tt.want, got)
}
}
func trimNullBytes(s string) string {
for len(s) > 0 && s[len(s)-1] == 0 {
s = s[:len(s)-1]
}
return s
}

View File

@@ -124,3 +124,11 @@ func WithVerifierWaiter(v *verification.InitializerWaiter) Option {
return nil
}
}
// WithGraffitiInfo sets the GraffitiInfo for client version tracking.
func WithGraffitiInfo(g *GraffitiInfo) Option {
return func(s *Service) error {
s.graffitiInfo = g
return nil
}
}

View File

@@ -162,6 +162,7 @@ type Service struct {
verifierWaiter *verification.InitializerWaiter
blobVerifier verification.NewBlobVerifier
capabilityCache *capabilityCache
graffitiInfo *GraffitiInfo
}
// NewService sets up a new instance with an ethclient when given a web3 endpoint as a string in the config.
@@ -318,6 +319,26 @@ func (s *Service) updateConnectedETH1(state bool) {
s.updateBeaconNodeStats()
}
// GraffitiInfo returns the GraffitiInfo struct for graffiti generation.
func (s *Service) GraffitiInfo() *GraffitiInfo {
return s.graffitiInfo
}
// updateGraffitiInfo fetches EL client version and updates the graffiti info.
func (s *Service) updateGraffitiInfo() {
if s.graffitiInfo == nil {
return
}
versions, err := s.GetClientVersion(s.ctx)
if err != nil {
log.WithError(err).Debug("Could not get execution client version for graffiti")
return
}
if len(versions) >= 1 {
s.graffitiInfo.UpdateFromEngine(versions[0].Code, versions[0].Commit)
}
}
// refers to the latest eth1 block which follows the condition: eth1_timestamp +
// SECONDS_PER_ETH1_BLOCK * ETH1_FOLLOW_DISTANCE <= current_unix_time
func (s *Service) followedBlockHeight(ctx context.Context) (uint64, error) {
@@ -598,6 +619,12 @@ func (s *Service) run(done <-chan struct{}) {
chainstartTicker := time.NewTicker(logPeriod)
defer chainstartTicker.Stop()
// Update graffiti info 4 times per epoch (~96 seconds with 12s slots and 32 slots/epoch)
graffitiTicker := time.NewTicker(96 * time.Second)
defer graffitiTicker.Stop()
// Initial update
s.updateGraffitiInfo()
for {
select {
case <-done:
@@ -622,6 +649,8 @@ func (s *Service) run(done <-chan struct{}) {
continue
}
s.logTillChainStart(context.Background())
case <-graffitiTicker.C:
s.updateGraffitiInfo()
}
}
}

View File

@@ -772,6 +772,9 @@ func (b *BeaconNode) registerPOWChainService() error {
return err
}
// Create GraffitiInfo for client version tracking in block graffiti
graffitiInfo := execution.NewGraffitiInfo("")
// skipcq: CRT-D0001
opts := append(
b.serviceFlagOpts.executionChainFlagOpts,
@@ -784,6 +787,7 @@ func (b *BeaconNode) registerPOWChainService() error {
execution.WithFinalizedStateAtStartup(b.finalizedStateAtStartUp),
execution.WithJwtId(b.cliCtx.String(flags.JwtId.Name)),
execution.WithVerifierWaiter(b.verifyInitWaiter),
execution.WithGraffitiInfo(graffitiInfo),
)
web3Service, err := execution.NewService(b.ctx, opts...)
if err != nil {
@@ -989,6 +993,7 @@ func (b *BeaconNode) registerRPCService(router *http.ServeMux) error {
TrackedValidatorsCache: b.trackedValidatorsCache,
PayloadIDCache: b.payloadIDCache,
LCStore: b.lcStore,
GraffitiInfo: web3Service.GraffitiInfo(),
})
return b.services.RegisterService(rpcService)

View File

@@ -89,7 +89,13 @@ func (vs *Server) GetBeaconBlock(ctx context.Context, req *ethpb.BlockRequest) (
}
// Set slot, graffiti, randao reveal, and parent root.
sBlk.SetSlot(req.Slot)
sBlk.SetGraffiti(req.Graffiti)
// Generate graffiti with client version info using flexible standard
if vs.GraffitiInfo != nil {
graffiti := vs.GraffitiInfo.GenerateGraffitiWithUserInput(req.Graffiti)
sBlk.SetGraffiti(graffiti[:])
} else {
sBlk.SetGraffiti(req.Graffiti)
}
sBlk.SetRandaoReveal(req.RandaoReveal)
sBlk.SetParentRoot(parentRoot[:])

View File

@@ -83,6 +83,7 @@ type Server struct {
ClockWaiter startup.ClockWaiter
CoreService *core.Service
AttestationStateFetcher blockchain.AttestationStateFetcher
GraffitiInfo *execution.GraffitiInfo
}
// Deprecated: The gRPC API will remain the default and fully supported through v8 (expected in 2026) but will be eventually removed in favor of REST API.

View File

@@ -125,6 +125,7 @@ type Config struct {
TrackedValidatorsCache *cache.TrackedValidatorsCache
PayloadIDCache *cache.PayloadIDCache
LCStore *lightClient.Store
GraffitiInfo *execution.GraffitiInfo
}
// NewService instantiates a new RPC service instance that will
@@ -256,6 +257,7 @@ func NewService(ctx context.Context, cfg *Config) *Service {
TrackedValidatorsCache: s.cfg.TrackedValidatorsCache,
PayloadIDCache: s.cfg.PayloadIDCache,
AttestationStateFetcher: s.cfg.AttestationReceiver,
GraffitiInfo: s.cfg.GraffitiInfo,
}
s.validatorServer = validatorServer
nodeServer := &nodev1alpha1.Server{

View File

@@ -0,0 +1,3 @@
### Added
- Graffiti implementation based on the design doc.

View File

@@ -47,3 +47,20 @@ func BuildData() string {
}
return fmt.Sprintf("Prysm/%s/%s", gitTag, gitCommit)
}
// GetCommitPrefix returns the first 4 hex characters of the git commit.
// This is used for graffiti generation per the client identification spec.
func GetCommitPrefix() string {
// Ensure gitCommit is populated
if gitCommit == "{STABLE_GIT_COMMIT}" {
commit, err := exec.Command("git", "rev-parse", "HEAD").Output()
if err != nil {
return ""
}
gitCommit = strings.TrimRight(string(commit), "\r\n")
}
if len(gitCommit) < 4 {
return gitCommit
}
return gitCommit[:4]
}