Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
118 changes: 113 additions & 5 deletions cl/beacon/handler/block_production.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package handler
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -61,6 +60,7 @@ import (
"github.com/erigontech/erigon/common/length"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/version"
"github.com/erigontech/erigon/execution/engineapi/engine_types"
"github.com/erigontech/erigon/execution/protocol/params"
"github.com/erigontech/erigon/execution/rlp"
Expand All @@ -80,7 +80,10 @@ var (
errBuilderNotEnabled = errors.New("builder is not enabled")
)

var defaultGraffitiString = "Caplin"
const (
caplinClientCode = "CN"
caplinClientName = "caplin"
)

const minPayloadPollingWindow = 100 * time.Millisecond

Expand All @@ -89,6 +92,109 @@ const minPayloadPollingWindow = 100 * time.Millisecond
// produced block still reaches attesters in time to earn the proposer boost.
const payloadPublicationDivisor = 4

// defaultGraffiti is used when the validator does not specify a graffiti. It follows the
// client-version graffiti standard, encoding the execution and consensus client codes and
// their commit prefixes so client-diversity tooling can attribute proposed blocks. See
// https://github.com/ethereum/execution-apis/blob/main/src/engine/identification.md
func (a *ApiHandler) defaultGraffiti() common.Hash {
graffiti := caplinClientCode + graffitiCommitPrefix(version.GitCommit)
if el := a.executionClientVersion(); el != nil {
graffiti = graffitiClientCode(el.Code) + graffitiCommitPrefix(el.Commit) + graffiti
}
return graffitiFromString(graffiti)
}

// elClientVersionUnavailable is a sentinel cached when the execution client does not
// implement engine_getClientVersionV1, so the negative outcome is memoized too.
var elClientVersionUnavailable = &engine_types.ClientVersionV1{}

// executionClientVersion returns the connected execution client's version if it is already
// cached, otherwise it kicks off a one-shot background fetch and returns nil. Block
// production never blocks on the engine API: the first proposal after startup falls back to
// consensus-only graffiti and later proposals pick up the execution client code once the
// fetch has populated the cache (the version is static for the lifetime of a connection).
func (a *ApiHandler) executionClientVersion() *engine_types.ClientVersionV1 {
if cached := a.elClientVersion.Load(); cached != nil {
return normalizeELClientVersion(cached)
}
a.triggerELClientVersionFetch()
return nil
}

// triggerELClientVersionFetch starts a single background fetch of the execution client
// version, unless one is already in flight or the version is already cached.
func (a *ApiHandler) triggerELClientVersionFetch() {
if a.engine == nil || a.elClientVersion.Load() != nil {
return
}
if !a.elClientVersionFetching.CompareAndSwap(false, true) {
return
}
go func() {
defer a.elClientVersionFetching.Store(false)
a.fetchExecutionClientVersion()
}()
}

// fetchExecutionClientVersion queries the engine once and caches the outcome. Transient
// errors are left uncached so a later fetch can retry; only a genuinely unsupported method
// (JSON-RPC -32601) or an empty result is memoized as unavailable.
func (a *ApiHandler) fetchExecutionClientVersion() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
caplin := engine_types.NewClientVersionV1(caplinClientCode, caplinClientName, a.version, version.GitCommit)
versions, err := a.engine.GetClientVersionV1(ctx, &caplin)
if err != nil {
if methodNotFound(err) {
a.elClientVersion.Store(elClientVersionUnavailable)
}
return
}
if len(versions) == 0 {
a.elClientVersion.Store(elClientVersionUnavailable)
return
}
el := versions[0]
a.elClientVersion.Store(&el)
}

func normalizeELClientVersion(v *engine_types.ClientVersionV1) *engine_types.ClientVersionV1 {
if v == nil || v == elClientVersionUnavailable {
return nil
}
return v
}

// graffitiClientCode clamps a client code to the 2 bytes the graffiti standard reserves for
// it, guarding against a non-conforming execution client returning an over-long code.
func graffitiClientCode(code string) string {
if len(code) > 2 {
return code[:2]
}
return code
}

// methodNotFound reports whether err is a JSON-RPC "method not found" (-32601) error.
func methodNotFound(err error) bool {
var coder interface{ ErrorCode() int }
return errors.As(err, &coder) && coder.ErrorCode() == -32601
}

// graffitiCommitPrefix returns the leading 2 bytes (4 hex chars) of a commit hash.
func graffitiCommitPrefix(commit string) string {
commit = strings.TrimPrefix(commit, "0x")
if len(commit) >= 4 {
return commit[:4]
}
return commit + strings.Repeat("0", 4-len(commit))
}

func graffitiFromString(s string) common.Hash {
var graffiti common.Hash
copy(graffiti[:], s)
return graffiti
}

type blockBuilderWindow struct {
firstGetAt time.Time
pollUntil time.Time
Expand Down Expand Up @@ -310,9 +416,11 @@ func (a *ApiHandler) GetEthV3ValidatorBlock(
if r.URL.Query().Has("skip_randao_verification") {
randaoReveal = common.Bytes96{0xc0} // infinity bls signature
}
graffiti := common.HexToHash(r.URL.Query().Get("graffiti"))
if !r.URL.Query().Has("graffiti") {
graffiti = common.HexToHash(hex.EncodeToString([]byte(defaultGraffitiString)))
var graffiti common.Hash
if r.URL.Query().Has("graffiti") {
graffiti = common.HexToHash(r.URL.Query().Get("graffiti"))
} else {
graffiti = a.defaultGraffiti()
}

tx, err := a.indiciesDB.BeginRo(ctx)
Expand Down
189 changes: 189 additions & 0 deletions cl/beacon/handler/block_production_graffiti_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package handler

import (
"bytes"
"context"
"errors"
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/db/version"
"github.com/erigontech/erigon/execution/engineapi/engine_types"
)

func graffitiText(g common.Hash) string {
return string(bytes.TrimRight(g[:], "\x00"))
}

type rpcError struct{ code int }

func (e rpcError) Error() string { return "rpc error" }
func (e rpcError) ErrorCode() int { return e.code }

func TestGraffitiCommitPrefix(t *testing.T) {
require.Equal(t, "a53e", graffitiCommitPrefix("0xa53e9545"))
require.Equal(t, "a53e", graffitiCommitPrefix("a53e9545"))
require.Equal(t, "ab00", graffitiCommitPrefix("ab"))
require.Equal(t, "0000", graffitiCommitPrefix(""))
}

func TestGraffitiClientCode(t *testing.T) {
require.Equal(t, "GE", graffitiClientCode("GE"))
require.Equal(t, "GE", graffitiClientCode("GETH"))
require.Equal(t, "N", graffitiClientCode("N"))
}

func TestFetchExecutionClientVersion(t *testing.T) {
t.Run("available is cached", func(t *testing.T) {
ctrl := gomock.NewController(t)
engine := execution_client.NewMockExecutionEngine(ctrl)
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
Return([]engine_types.ClientVersionV1{{Code: "GE", Commit: "0xc3d4e5f6"}}, nil).
Times(1)

a := &ApiHandler{engine: engine, version: "1.2.3"}
a.fetchExecutionClientVersion()
got := a.elClientVersion.Load()
require.NotNil(t, got)
require.Equal(t, "GE", got.Code)
})

t.Run("method-not-found is cached as unavailable", func(t *testing.T) {
ctrl := gomock.NewController(t)
engine := execution_client.NewMockExecutionEngine(ctrl)
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
Return(nil, rpcError{code: -32601}).
Times(1)

a := &ApiHandler{engine: engine, version: "1.2.3"}
a.fetchExecutionClientVersion()
require.Same(t, elClientVersionUnavailable, a.elClientVersion.Load())
})

t.Run("empty version list is cached as unavailable", func(t *testing.T) {
ctrl := gomock.NewController(t)
engine := execution_client.NewMockExecutionEngine(ctrl)
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
Return([]engine_types.ClientVersionV1{}, nil).
Times(1)

a := &ApiHandler{engine: engine, version: "1.2.3"}
a.fetchExecutionClientVersion()
require.Same(t, elClientVersionUnavailable, a.elClientVersion.Load())
})

t.Run("transient error is not cached and a later fetch can succeed", func(t *testing.T) {
ctrl := gomock.NewController(t)
engine := execution_client.NewMockExecutionEngine(ctrl)
gomock.InOrder(
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
Return(nil, errors.New("timeout")),
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
Return([]engine_types.ClientVersionV1{{Code: "GE", Commit: "0xc3d4e5f6"}}, nil),
)

a := &ApiHandler{engine: engine, version: "1.2.3"}
a.fetchExecutionClientVersion()
require.Nil(t, a.elClientVersion.Load())
a.fetchExecutionClientVersion()
got := a.elClientVersion.Load()
require.NotNil(t, got)
require.Equal(t, "GE", got.Code)
})
}

func TestDefaultGraffiti(t *testing.T) {
clCommit := graffitiCommitPrefix(version.GitCommit)

t.Run("cached execution client version yields full graffiti", func(t *testing.T) {
a := &ApiHandler{version: "1.2.3"}
a.elClientVersion.Store(&engine_types.ClientVersionV1{Code: "GE", Commit: "0xc3d4e5f6"})
require.Equal(t, "GEc3d4"+caplinClientCode+clCommit, graffitiText(a.defaultGraffiti()))
})

t.Run("over-long execution client code is clamped to two bytes", func(t *testing.T) {
a := &ApiHandler{version: "1.2.3"}
a.elClientVersion.Store(&engine_types.ClientVersionV1{Code: "GETH", Commit: "0xc3d4e5f6"})
require.Equal(t, "GEc3d4"+caplinClientCode+clCommit, graffitiText(a.defaultGraffiti()))
})

t.Run("cached-unavailable yields consensus-only", func(t *testing.T) {
a := &ApiHandler{version: "1.2.3"}
a.elClientVersion.Store(elClientVersionUnavailable)
require.Equal(t, caplinClientCode+clCommit, graffitiText(a.defaultGraffiti()))
})

t.Run("no engine yields consensus-only", func(t *testing.T) {
a := &ApiHandler{version: "1.2.3"}
require.Equal(t, caplinClientCode+clCommit, graffitiText(a.defaultGraffiti()))
})

t.Run("cold cache does not block and fills asynchronously", func(t *testing.T) {
ctrl := gomock.NewController(t)
engine := execution_client.NewMockExecutionEngine(ctrl)
release := make(chan struct{})
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
DoAndReturn(func(context.Context, *engine_types.ClientVersionV1) ([]engine_types.ClientVersionV1, error) {
<-release
return []engine_types.ClientVersionV1{{Code: "GE", Commit: "0xc3d4e5f6"}}, nil
}).
Times(1)

a := &ApiHandler{engine: engine, version: "1.2.3"}
// Returns immediately with consensus-only graffiti while the engine call is still blocked.
require.Equal(t, caplinClientCode+clCommit, graffitiText(a.defaultGraffiti()))
close(release)
require.Eventually(t, func() bool {
return graffitiText(a.defaultGraffiti()) == "GEc3d4"+caplinClientCode+clCommit
}, time.Second, time.Millisecond)
})

t.Run("concurrent cold-cache proposals trigger at most one fetch", func(t *testing.T) {
ctrl := gomock.NewController(t)
engine := execution_client.NewMockExecutionEngine(ctrl)
release := make(chan struct{})
engine.EXPECT().GetClientVersionV1(gomock.Any(), gomock.Any()).
DoAndReturn(func(context.Context, *engine_types.ClientVersionV1) ([]engine_types.ClientVersionV1, error) {
<-release
return []engine_types.ClientVersionV1{{Code: "GE", Commit: "0xc3d4e5f6"}}, nil
}).
Times(1)

a := &ApiHandler{engine: engine, version: "1.2.3"}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = a.defaultGraffiti()
}()
}
wg.Wait() // returns while the engine call is still blocked, proving proposals never block on it
close(release)
require.Eventually(t, func() bool {
return graffitiText(a.defaultGraffiti()) == "GEc3d4"+caplinClientCode+clCommit
}, time.Second, time.Millisecond)
})
}
4 changes: 4 additions & 0 deletions cl/beacon/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"errors"
"net/http"
"sync"
"sync/atomic"

"github.com/go-chi/chi/v5"

Expand Down Expand Up @@ -51,6 +52,7 @@ import (
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/snapshotsync"
"github.com/erigontech/erigon/db/snapshotsync/freezeblocks"
"github.com/erigontech/erigon/execution/engineapi/engine_types"
"github.com/erigontech/erigon/node/gointerfaces/sentinelproto"
)

Expand Down Expand Up @@ -108,6 +110,8 @@ type ApiHandler struct {
validatorParams *validator_params.ValidatorParams
blobBundles *lru.Cache[common.Bytes48, BlobBundle] // Keep recent bundled blobs from the execution layer.
engine execution_client.ExecutionEngine
elClientVersion atomic.Pointer[engine_types.ClientVersionV1] // Cached execution client version for default graffiti.
elClientVersionFetching atomic.Bool // Guards a single in-flight background elClientVersion fetch.
syncMessagePool sync_contribution_pool.SyncContributionPool
committeeSub *committee_subscription.CommitteeSubscribeMgmt
attestationProducer attestation_producer.AttestationDataProducer
Expand Down
5 changes: 5 additions & 0 deletions cl/phase1/execution_client/execution_client_direct.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,8 @@ func (cc *ExecutionClientDirect) GetBlobs(ctx context.Context, versionedHashes [
}
return blobs, proofs, nil
}

// In direct mode the execution layer is the in-process Erigon node, so report it directly.
func (cc *ExecutionClientDirect) GetClientVersionV1(_ context.Context, _ *engine_types.ClientVersionV1) ([]engine_types.ClientVersionV1, error) {
return []engine_types.ClientVersionV1{engine_types.LocalClientVersionV1()}, nil
}
4 changes: 4 additions & 0 deletions cl/phase1/execution_client/execution_client_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -611,3 +611,7 @@ func (cc *ExecutionClientEngine) GetBlobs(ctx context.Context, versionedHashes [
}
return blobs, proofs, nil
}

func (cc *ExecutionClientEngine) GetClientVersionV1(ctx context.Context, callerVersion *engine_types.ClientVersionV1) ([]engine_types.ClientVersionV1, error) {
return cc.engine.GetClientVersionV1(ctx, callerVersion)
}
Loading
Loading