From 6cb3677541b88e2db177404fba72ca521ba01017 Mon Sep 17 00:00:00 2001 From: satushh Date: Wed, 3 Dec 2025 14:44:30 +0000 Subject: [PATCH] graffiti initial implementation that works locally on kurtosis --- beacon-chain/execution/BUILD.bazel | 2 + beacon-chain/execution/engine_client.go | 31 +++ beacon-chain/execution/graffiti_info.go | 160 +++++++++++++++ beacon-chain/execution/graffiti_info_test.go | 185 ++++++++++++++++++ beacon-chain/execution/options.go | 8 + beacon-chain/execution/service.go | 29 +++ beacon-chain/node/node.go | 5 + .../rpc/prysm/v1alpha1/validator/proposer.go | 8 +- .../rpc/prysm/v1alpha1/validator/server.go | 1 + beacon-chain/rpc/service.go | 2 + changelog/satushh-graffiti-impl.md | 3 + runtime/version/version.go | 17 ++ 12 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 beacon-chain/execution/graffiti_info.go create mode 100644 beacon-chain/execution/graffiti_info_test.go create mode 100644 changelog/satushh-graffiti-impl.md diff --git a/beacon-chain/execution/BUILD.bazel b/beacon-chain/execution/BUILD.bazel index 93d7343027..6e54afabef 100644 --- a/beacon-chain/execution/BUILD.bazel +++ b/beacon-chain/execution/BUILD.bazel @@ -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", diff --git a/beacon-chain/execution/engine_client.go b/beacon-chain/execution/engine_client.go index 43702a9596..5e6101238c 100644 --- a/beacon-chain/execution/engine_client.go +++ b/beacon-chain/execution/engine_client.go @@ -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: diff --git a/beacon-chain/execution/graffiti_info.go b/beacon-chain/execution/graffiti_info.go new file mode 100644 index 0000000000..233f3a594c --- /dev/null +++ b/beacon-chain/execution/graffiti_info.go @@ -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 +} diff --git a/beacon-chain/execution/graffiti_info_test.go b/beacon-chain/execution/graffiti_info_test.go new file mode 100644 index 0000000000..ab11042da6 --- /dev/null +++ b/beacon-chain/execution/graffiti_info_test.go @@ -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 +} diff --git a/beacon-chain/execution/options.go b/beacon-chain/execution/options.go index 7d178671ce..e7918b1c1b 100644 --- a/beacon-chain/execution/options.go +++ b/beacon-chain/execution/options.go @@ -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 + } +} diff --git a/beacon-chain/execution/service.go b/beacon-chain/execution/service.go index f9d35fd7a5..8b250c9923 100644 --- a/beacon-chain/execution/service.go +++ b/beacon-chain/execution/service.go @@ -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() } } } diff --git a/beacon-chain/node/node.go b/beacon-chain/node/node.go index 8569c561b3..92e6981d9f 100644 --- a/beacon-chain/node/node.go +++ b/beacon-chain/node/node.go @@ -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) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer.go index 764d80b0aa..6a5234af34 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer.go @@ -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[:]) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/server.go b/beacon-chain/rpc/prysm/v1alpha1/validator/server.go index aaba034c12..ece565f211 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/server.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/server.go @@ -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. diff --git a/beacon-chain/rpc/service.go b/beacon-chain/rpc/service.go index 8953780f8a..a054d51d5a 100644 --- a/beacon-chain/rpc/service.go +++ b/beacon-chain/rpc/service.go @@ -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{ diff --git a/changelog/satushh-graffiti-impl.md b/changelog/satushh-graffiti-impl.md new file mode 100644 index 0000000000..b2f80a029c --- /dev/null +++ b/changelog/satushh-graffiti-impl.md @@ -0,0 +1,3 @@ +### Added + +- Graffiti implementation based on the design doc. \ No newline at end of file diff --git a/runtime/version/version.go b/runtime/version/version.go index a7ba869747..a45be8bae6 100644 --- a/runtime/version/version.go +++ b/runtime/version/version.go @@ -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] +}