diff --git a/cmd/root.go b/cmd/root.go index 3d59ba34..9f7e9c04 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,9 @@ func init() { rootCmd.PersistentFlags().Bool("epbs-enabled", false, "Enable ePBS bidding/revealing at startup") rootCmd.PersistentFlags().Bool("builder-api-enabled", defaults.BuilderAPIEnabled, "Enable traditional Builder API at startup (served on --api-port)") rootCmd.PersistentFlags().Uint64("builder-api-subsidy", defaults.BuilderAPI.BlockValueSubsidyGwei, "Block value subsidy added to bids in Gwei") + rootCmd.PersistentFlags().Uint64("gloas-builder-api-subsidy", defaults.BuilderAPI.GloasBuilderApiSubsidy, "Gwei added to block value to form ExecutionPayment in Gloas Builder API bids") + rootCmd.PersistentFlags().String("builder-api-url", defaults.BuilderAPI.BuilderURL, "Publicly reachable URL of this builder (e.g. https://builder.example.com); used to validate builder_url in SignedRequestAuthV1") + rootCmd.PersistentFlags().Bool("builder-api-require-auth", defaults.BuilderAPI.RequireRequestAuth, "Require SignedRequestAuthV1 on getExecutionPayloadBid requests; reject unauthenticated requests with 401") rootCmd.PersistentFlags().Uint64("deposit-amount", defaults.DepositAmount, "Builder deposit amount in Gwei") rootCmd.PersistentFlags().Uint64("topup-threshold", defaults.TopupThreshold, "Balance threshold for auto top-up in Gwei") rootCmd.PersistentFlags().Uint64("topup-amount", defaults.TopupAmount, "Amount to top-up in Gwei") @@ -162,7 +165,10 @@ func initConfig() error { EPBSEnabled: v.GetBool("epbs-enabled"), BuilderAPIEnabled: v.GetBool("builder-api-enabled"), BuilderAPI: builder.BuilderAPIConfig{ - BlockValueSubsidyGwei: v.GetUint64("builder-api-subsidy"), + BuilderURL: v.GetString("builder-api-url"), + RequireRequestAuth: v.GetBool("builder-api-require-auth"), + BlockValueSubsidyGwei: v.GetUint64("builder-api-subsidy"), + GloasBuilderApiSubsidy: v.GetUint64("gloas-builder-api-subsidy"), }, DepositAmount: v.GetUint64("deposit-amount"), TopupThreshold: v.GetUint64("topup-threshold"), diff --git a/cmd/run.go b/cmd/run.go index 6b174fe9..f77a3d5e 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -281,7 +281,7 @@ and begins building blocks according to configuration.`, } genesisForkVersion := g.GenesisForkVersion - genesisValidatorsRoot := phase0.Root{} + genesisValidatorsRoot := g.GenesisValidatorsRoot logger.WithFields(logrus.Fields{ "genesis_fork_version": fmt.Sprintf("0x%x", genesisForkVersion[:]), @@ -296,7 +296,9 @@ and begins building blocks according to configuration.`, builderAPISrv = builderapi.NewServer(&cfg.BuilderAPI, logger, builderSvc, blsSigner, validatorStore, genesisForkVersion, forkVersion, genesisValidatorsRoot) builderAPISrv.SetFuluPublisher(clClient) + builderAPISrv.SetCLClient(clClient) builderAPISrv.SetEnabled(cfg.BuilderAPIEnabled) + builderAPISrv.SetChainService(chainSvc) builderAPISrv.SetStateDB(stateDB) } @@ -307,6 +309,9 @@ and begins building blocks according to configuration.`, propPrefSvc = proposerpreferences.NewService(clClient, logger) propPrefSvc.GetCache().SetStateDB(stateDB, logger) builderSvc.SetProposerPreferencesCache(propPrefSvc.GetCache()) + if builderAPISrv != nil { + builderAPISrv.SetProposerPreferencesCache(propPrefSvc.GetCache()) + } chainSvc.SetProposerPreferencesCache(propPrefSvc.GetCache()) } @@ -371,6 +376,9 @@ and begins building blocks according to configuration.`, }) lifecycleMgr.SetRegistrationCallback(func(index uint64) { epbsSvc.SetBuilderRegistered(index) + if builderAPISrv != nil { + builderAPISrv.SetBuilderIndex(index) + } }) } diff --git a/go.mod b/go.mod index a3dbd5a3..47d52a51 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,14 @@ require ( github.com/ethpandaops/go-eth2-client v0.1.3 github.com/ethpandaops/service-authenticatoor v0.0.1 github.com/glebarez/go-sqlite v1.22.0 + github.com/goccy/go-yaml v1.19.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/mux v1.8.1 github.com/herumi/bls-eth-go-binary v1.37.0 github.com/holiman/uint256 v1.3.2 github.com/jmoiron/sqlx v1.4.0 github.com/pk910/dynamic-ssz v1.3.2-0.20260505131440-111bcb265c8f + github.com/pkg/errors v0.9.1 github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/rs/zerolog v1.35.1 @@ -46,7 +48,7 @@ require ( github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emicklei/dot v1.6.2 // indirect + github.com/emicklei/dot v1.6.4 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -58,12 +60,11 @@ require ( github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/huandu/go-clone v1.6.0 // indirect + github.com/huandu/go-clone v1.7.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -71,8 +72,8 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/minio/sha256-simd v1.0.0 // indirect - github.com/mitchellh/mapstructure v1.4.1 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -81,7 +82,6 @@ require ( github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pk910/hashtree-bindings v0.1.0 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -122,3 +122,5 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.49.1 // indirect ) + +tool github.com/pk910/dynamic-ssz/dynssz-gen diff --git a/go.sum b/go.sum index 9f2f032e..c191cdad 100644 --- a/go.sum +++ b/go.sum @@ -58,8 +58,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= -github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/emicklei/dot v1.6.4 h1:cG9ycT67d9Yw22G+mAb4XiuUz6E6H1S0zePp/5Cwe/c= +github.com/emicklei/dot v1.6.4/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= @@ -143,8 +143,8 @@ github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-clone v1.6.0 h1:HMo5uvg4wgfiy5FoGOqlFLQED/VGRm2D9Pi8g1FXPGc= -github.com/huandu/go-clone v1.6.0/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-clone v1.7.2 h1:3+Aq0Ed8XK+zKkLjE2dfHg0XrpIfcohBE1K+c8Usxoo= +github.com/huandu/go-clone v1.7.2/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/huandu/go-clone/generic v1.6.0 h1:Wgmt/fUZ28r16F2Y3APotFD59sHk1p78K0XLdbUYN5U= github.com/huandu/go-clone/generic v1.6.0/go.mod h1:xgd9ZebcMsBWWcBx5mVMCoqMX24gLWr5lQicr+nVXNs= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= @@ -159,7 +159,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -182,10 +181,10 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= -github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= -github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= diff --git a/pkg/builderapi/builder_preferences.go b/pkg/builderapi/builder_preferences.go new file mode 100644 index 00000000..1eabdab5 --- /dev/null +++ b/pkg/builderapi/builder_preferences.go @@ -0,0 +1,68 @@ +package builderapi + +import ( + "maps" + "sync" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" +) + +// BuilderPreferencesStore holds the latest per-validator builder preferences +// submitted via the submitBuilderPreferences API. It keeps only the most recent +// max_execution_payment for each validator pubkey (a later submission overwrites +// an earlier one). +// +// Per the Gloas builder-specs, if no preferences have been submitted for a +// validator, the builder MUST treat its max_execution_payment as 0; GetOrDefault +// encodes that rule. +type BuilderPreferencesStore struct { + mu sync.RWMutex + prefs map[phase0.BLSPubKey]phase0.Gwei +} + +// NewBuilderPreferencesStore creates an empty BuilderPreferencesStore. +func NewBuilderPreferencesStore() *BuilderPreferencesStore { + return &BuilderPreferencesStore{ + prefs: make(map[phase0.BLSPubKey]phase0.Gwei), + } +} + +// Set records the latest max_execution_payment for a validator, overwriting any +// previously stored value. +func (s *BuilderPreferencesStore) Set(pubkey phase0.BLSPubKey, maxExecutionPayment phase0.Gwei) { + s.mu.Lock() + defer s.mu.Unlock() + s.prefs[pubkey] = maxExecutionPayment +} + +// Get returns the stored max_execution_payment for a validator and whether a +// preference was found. +func (s *BuilderPreferencesStore) Get(pubkey phase0.BLSPubKey) (phase0.Gwei, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + v, ok := s.prefs[pubkey] + return v, ok +} + +// GetOrDefault returns the stored max_execution_payment for a validator, or 0 if +// none has been submitted — the spec-mandated default that disallows execution +// layer payments. +func (s *BuilderPreferencesStore) GetOrDefault(pubkey phase0.BLSPubKey) phase0.Gwei { + s.mu.RLock() + defer s.mu.RUnlock() + prefs, ok := s.prefs[pubkey] + if !ok { + return 0 + } + return prefs +} + +// GetAll returns a snapshot copy of all stored builder preferences, keyed by +// validator pubkey. +func (s *BuilderPreferencesStore) GetAll() map[phase0.BLSPubKey]phase0.Gwei { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[phase0.BLSPubKey]phase0.Gwei, len(s.prefs)) + maps.Copy(out, s.prefs) + return out +} diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go new file mode 100644 index 00000000..86a7fb6e --- /dev/null +++ b/pkg/builderapi/builder_preferences_test.go @@ -0,0 +1,317 @@ +package builderapi + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gloasauth "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" + "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/signer" +) + +const ( + testValidatorPrivkey = "0x0000000000000000000000000000000000000000000000000000000000000001" + testOtherPrivkey = "0x0000000000000000000000000000000000000000000000000000000000000002" + testBuilderURL = "https://builder.example.com" +) + +// signBuilderPrefsRequest builds a JSON BuilderPreferencesRequestV1 whose auth is +// signed by s over (builderURL, slot) using DOMAIN_REQUEST_AUTH at the given +// genesis fork version (GVR=zero per spec). +func signBuilderPrefsRequest( + t *testing.T, + s *signer.BLSSigner, + builderURL string, + slot phase0.Slot, + maxPayment phase0.Gwei, + genesisForkVersion phase0.Version, +) []byte { + t.Helper() + + auth := &gloastypes.RequestAuthV1{ + Data: []byte(builderURL), + Slot: slot, + } + root, err := auth.HashTreeRoot() + require.NoError(t, err) + + domain := signer.ComputeDomain(gloasauth.DomainRequestAuth, genesisForkVersion, phase0.Root{}) + sig, err := s.SignWithDomain(phase0.Root(root), domain) + require.NoError(t, err) + + req := &gloastypes.BuilderPreferencesRequestV1{ + Preferences: &gloastypes.BuilderPreferencesV1{MaxExecutionPayment: maxPayment}, + Auth: &gloastypes.SignedRequestAuthV1{ + Message: auth, + Signature: sig, + }, + } + body, err := json.Marshal(req) + require.NoError(t, err) + return body +} + +func TestBuilderPreferencesStore(t *testing.T) { + store := NewBuilderPreferencesStore() + + var pk1, pk2 phase0.BLSPubKey + pk1[0] = 1 + pk2[0] = 2 + + // Absent → GetOrDefault is 0, Get reports not found. + assert.Equal(t, phase0.Gwei(0), store.GetOrDefault(pk1)) + _, ok := store.Get(pk1) + assert.False(t, ok) + + // Set then read back. + store.Set(pk1, 100) + got, ok := store.Get(pk1) + require.True(t, ok) + assert.Equal(t, phase0.Gwei(100), got) + assert.Equal(t, phase0.Gwei(100), store.GetOrDefault(pk1)) + + // Overwrite keeps only the latest value. + store.Set(pk1, 250) + got, _ = store.Get(pk1) + assert.Equal(t, phase0.Gwei(250), got) + + store.Set(pk2, 7) + + // GetAll returns a snapshot of all entries. + all := store.GetAll() + require.Len(t, all, 2) + assert.Equal(t, phase0.Gwei(250), all[pk1]) + assert.Equal(t, phase0.Gwei(7), all[pk2]) + + // The snapshot is a copy — mutating it must not affect the store. + all[pk1] = 999 + got, _ = store.Get(pk1) + assert.Equal(t, phase0.Gwei(250), got) +} + +func TestSubmitBuilderPreferences_Success(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + body := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusAccepted, rec.Code) + got, ok := srv.builderPrefsStore.Get(pk) + require.True(t, ok, "preference should be stored after successful auth") + assert.Equal(t, phase0.Gwei(5_000_000_000), got) +} + +func TestSubmitBuilderPreferences_SuccessSSZ(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + // Build the same signed request as the JSON path, but submit it SSZ-encoded. + jsonBody := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) + var prefsReq gloastypes.BuilderPreferencesRequestV1 + require.NoError(t, json.Unmarshal(jsonBody, &prefsReq)) + sszBody, err := prefsReq.MarshalSSZ() + require.NoError(t, err) + + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(sszBody)) + req.Header.Set("Content-Type", "application/octet-stream") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusAccepted, rec.Code) + got, ok := srv.builderPrefsStore.Get(pk) + require.True(t, ok, "preference should be stored after successful SSZ-decoded auth") + assert.Equal(t, phase0.Gwei(5_000_000_000), got) +} + +func TestSubmitBuilderPreferences_MalformedSSZ(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte{0x01, 0x02, 0x03})) + req.Header.Set("Content-Type", "application/octet-stream") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSubmitBuilderPreferences_UnknownContentType(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "text/plain") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code) +} + +func TestSubmitBuilderPreferences_LatestOverwrites(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + for _, v := range []phase0.Gwei{100, 250} { + body := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, v, gfv) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + require.Equal(t, http.StatusAccepted, rec.Code) + } + + got, _ := srv.builderPrefsStore.Get(pk) + assert.Equal(t, phase0.Gwei(250), got, "only the latest preference should be retained") +} + +func TestSubmitBuilderPreferences_WrongBuilderURL(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + // Validly signed, but for a different builder URL than this builder's. + body := signBuilderPrefsRequest(t, blsSigner, "https://other-builder.example.com", 100, 5_000_000_000, gfv) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + _, ok := srv.builderPrefsStore.Get(pk) + assert.False(t, ok, "preference must not be stored when builder_url does not match") +} + +func TestSubmitBuilderPreferences_BadSignature(t *testing.T) { + gfv := phase0.Version{} + validator, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + other, err := signer.NewBLSSigner(testOtherPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + // Signed by `other` (correct builder URL), but submitted under `validator`'s pubkey. + body := signBuilderPrefsRequest(t, other, testBuilderURL, 100, 5_000_000_000, gfv) + pk := validator.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + _, ok := srv.builderPrefsStore.Get(pk) + assert.False(t, ok, "preference must not be stored when signature verification fails") +} + +func TestSubmitBuilderPreferences_NoBuilderURLConfigured(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{} // BuilderURL empty + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + body := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSubmitBuilderPreferences_InvalidJSON(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("not json"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSubmitBuilderPreferences_MissingContentType(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}"))) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code) +} + +func TestSubmitBuilderPreferences_Disabled(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + // not enabled + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) +} diff --git a/pkg/builderapi/encoding.go b/pkg/builderapi/encoding.go new file mode 100644 index 00000000..d646962d --- /dev/null +++ b/pkg/builderapi/encoding.go @@ -0,0 +1,71 @@ +package builderapi + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" +) + +// Wire formats accepted on Gloas builder-API request bodies. The builder-spec +// permits either JSON or SSZ, selected via the Content-Type header. +const ( + contentTypeJSON = "application/json" + contentTypeSSZ = "application/octet-stream" +) + +// errUnsupportedContentType lets handlers map an unknown Content-Type to a 415 +// response, distinct from a decode failure (which is a 400). +var errUnsupportedContentType = errors.New("unsupported content type") + +// normalizeContentType lowercases the media type and drops any parameters +// (e.g. "application/json; charset=utf-8" -> "application/json"). +func normalizeContentType(ct string) string { + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return strings.ToLower(strings.TrimSpace(ct)) +} + +// parseSignedRequestAuth decodes a SignedRequestAuthV1 from JSON or SSZ selected +// by contentType. The Content-Type must be set explicitly to one of the two +// supported media types; an empty or unrecognized type returns +// errUnsupportedContentType (which handlers map to 415). +func parseSignedRequestAuth(data []byte, contentType string) (*gloastypes.SignedRequestAuthV1, error) { + var v gloastypes.SignedRequestAuthV1 + switch normalizeContentType(contentType) { + case contentTypeSSZ: + if err := v.UnmarshalSSZ(data); err != nil { + return nil, fmt.Errorf("invalid SSZ SignedRequestAuthV1: %w", err) + } + case contentTypeJSON: + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("invalid SignedRequestAuthV1: %w", err) + } + default: + return nil, errUnsupportedContentType + } + return &v, nil +} + +// parseBuilderPreferencesRequest decodes a BuilderPreferencesRequestV1 from JSON +// or SSZ selected by contentType, following the same rules as +// parseSignedRequestAuth. +func parseBuilderPreferencesRequest(data []byte, contentType string) (*gloastypes.BuilderPreferencesRequestV1, error) { + var v gloastypes.BuilderPreferencesRequestV1 + switch normalizeContentType(contentType) { + case contentTypeSSZ: + if err := v.UnmarshalSSZ(data); err != nil { + return nil, fmt.Errorf("invalid SSZ BuilderPreferencesRequestV1: %w", err) + } + case contentTypeJSON: + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("invalid BuilderPreferencesRequestV1: %w", err) + } + default: + return nil, errUnsupportedContentType + } + return &v, nil +} diff --git a/pkg/builderapi/encoding_test.go b/pkg/builderapi/encoding_test.go new file mode 100644 index 00000000..0daf79c5 --- /dev/null +++ b/pkg/builderapi/encoding_test.go @@ -0,0 +1,85 @@ +package builderapi + +import ( + "encoding/json" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" +) + +func sampleSignedRequestAuth() *gloastypes.SignedRequestAuthV1 { + var sig phase0.BLSSignature + sig[0] = 0xaa + return &gloastypes.SignedRequestAuthV1{ + Message: &gloastypes.RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: 42}, + Signature: sig, + } +} + +func TestParseSignedRequestAuth_JSONAndSSZRoundTrip(t *testing.T) { + orig := sampleSignedRequestAuth() + + jsonBody, err := json.Marshal(orig) + require.NoError(t, err) + sszBody, err := orig.MarshalSSZ() + require.NoError(t, err) + + fromJSON, err := parseSignedRequestAuth(jsonBody, "application/json") + require.NoError(t, err) + fromSSZ, err := parseSignedRequestAuth(sszBody, "application/octet-stream") + require.NoError(t, err) + + // Content-Type parameters are tolerated. + fromJSONParam, err := parseSignedRequestAuth(jsonBody, "application/json; charset=utf-8") + require.NoError(t, err) + + for _, got := range []*gloastypes.SignedRequestAuthV1{fromJSON, fromSSZ, fromJSONParam} { + require.NotNil(t, got.Message) + assert.Equal(t, orig.Message.Slot, got.Message.Slot) + assert.Equal(t, orig.Message.Data, got.Message.Data) + assert.Equal(t, orig.Signature, got.Signature) + } +} + +func TestParseSignedRequestAuth_Errors(t *testing.T) { + ssz, err := sampleSignedRequestAuth().MarshalSSZ() + require.NoError(t, err) + + // Unknown / empty Content-Type -> errUnsupportedContentType. + _, err = parseSignedRequestAuth(ssz, "text/plain") + require.ErrorIs(t, err, errUnsupportedContentType) + _, err = parseSignedRequestAuth(ssz, "") + require.ErrorIs(t, err, errUnsupportedContentType) + + // Malformed bodies -> decode error (not the content-type sentinel). + _, err = parseSignedRequestAuth([]byte("not json"), "application/json") + require.Error(t, err) + assert.NotErrorIs(t, err, errUnsupportedContentType) + _, err = parseSignedRequestAuth([]byte{0x01}, "application/octet-stream") + require.Error(t, err) + assert.NotErrorIs(t, err, errUnsupportedContentType) +} + +func TestParseBuilderPreferencesRequest_SSZRoundTrip(t *testing.T) { + orig := &gloastypes.BuilderPreferencesRequestV1{ + Preferences: &gloastypes.BuilderPreferencesV1{MaxExecutionPayment: 5_000_000_000}, + Auth: sampleSignedRequestAuth(), + } + + ssz, err := orig.MarshalSSZ() + require.NoError(t, err) + + got, err := parseBuilderPreferencesRequest(ssz, "application/octet-stream") + require.NoError(t, err) + require.NotNil(t, got.Preferences) + assert.Equal(t, orig.Preferences.MaxExecutionPayment, got.Preferences.MaxExecutionPayment) + require.NotNil(t, got.Auth) + assert.Equal(t, orig.Auth.Message.Slot, got.Auth.Message.Slot) + + _, err = parseBuilderPreferencesRequest(ssz, "") + require.ErrorIs(t, err, errUnsupportedContentType) +} diff --git a/pkg/builderapi/gloas/auth.go b/pkg/builderapi/gloas/auth.go new file mode 100644 index 00000000..8991f7fe --- /dev/null +++ b/pkg/builderapi/gloas/auth.go @@ -0,0 +1,62 @@ +// Package gloas implements the Gloas-fork Builder API handlers and helpers. +// See https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/specs/gloas/builder.md +package gloas + +import ( + "errors" + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" + "github.com/ethpandaops/buildoor/pkg/signer" +) + +// DomainRequestAuth is the DomainType used to sign SignedRequestAuth messages. +// Defined in builder-specs as DOMAIN_REQUEST_AUTH = DomainType('0x0B000001'). +var DomainRequestAuth = phase0.DomainType{0x0B, 0x00, 0x00, 0x01} + +var ( + // ErrNilSignedRequestAuth is returned when the SignedRequestAuth wrapper is nil. + ErrNilSignedRequestAuth = errors.New("signed request auth is nil") + // ErrNilRequestAuthMessage is returned when the inner RequestAuth message is nil. + ErrNilRequestAuthMessage = errors.New("request auth message is nil") + // ErrInvalidRequestAuthSignature is returned when BLS verification fails. + ErrInvalidRequestAuthSignature = errors.New("invalid request auth signature") +) + +// VerifyRequestAuth verifies the BLS signature on a SignedRequestAuth against +// the supplied validator public key. +// +// Per the Gloas builder-specs validator.md, the signing domain is +// compute_domain(DOMAIN_REQUEST_AUTH), which defaults to +// (GENESIS_FORK_VERSION, zero genesis_validators_root). Callers pass the +// chain's genesis fork version; the genesis_validators_root is always zero per +// spec. +// +// Returns nil on success, or one of the package's sentinel errors on failure. +func VerifyRequestAuth( + signed *gloastypes.SignedRequestAuthV1, + validatorPubkey phase0.BLSPubKey, + genesisForkVersion phase0.Version, +) error { + if signed == nil { + return ErrNilSignedRequestAuth + } + if signed.Message == nil { + return ErrNilRequestAuthMessage + } + + msgRoot, err := signed.Message.HashTreeRoot() + if err != nil { + return fmt.Errorf("failed to compute request auth hash tree root: %w", err) + } + + domain := signer.ComputeDomain(DomainRequestAuth, genesisForkVersion, phase0.Root{}) + signingRoot := signer.ComputeSigningRoot(msgRoot, domain) + + if !signer.VerifyBLSSignature(validatorPubkey, signingRoot[:], phase0.BLSSignature(signed.Signature)) { + return ErrInvalidRequestAuthSignature + } + return nil +} diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go new file mode 100644 index 00000000..4971c688 --- /dev/null +++ b/pkg/builderapi/gloas/auth_test.go @@ -0,0 +1,205 @@ +package gloas_test + +import ( + "strings" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" + "github.com/ethpandaops/buildoor/pkg/signer" +) + +const ( + validatorPrivkeyHex = "1111111111111111111111111111111111111111111111111111111111111111" + otherPrivkeyHex = "2222222222222222222222222222222222222222222222222222222222222222" + + testBuilderURL = "https://builder.example.com" + otherBuilderURL = "https://other-builder.example.com" +) + +// signRequestAuth builds and BLS-signs a RequestAuth with the given signer using +// DomainRequestAuth at the supplied genesis fork version (GVR=zero per spec). +func signRequestAuth( + t *testing.T, + s *signer.BLSSigner, + builderURL []byte, + slot phase0.Slot, + genesisForkVersion phase0.Version, +) *gloastypes.SignedRequestAuthV1 { + t.Helper() + + msg := &gloastypes.RequestAuthV1{ + Data: builderURL, + Slot: slot, + } + + root, err := msg.HashTreeRoot() + require.NoError(t, err) + + domain := signer.ComputeDomain(gloas.DomainRequestAuth, genesisForkVersion, phase0.Root{}) + + sig, err := s.SignWithDomain(root, domain) + require.NoError(t, err) + + return &gloastypes.SignedRequestAuthV1{ + Message: msg, + Signature: sig, + } +} + +func TestDomainRequestAuthValue(t *testing.T) { + // Spec: DOMAIN_REQUEST_AUTH = DomainType('0x0B000001') + require.Equal(t, phase0.DomainType{0x0B, 0x00, 0x00, 0x01}, gloas.DomainRequestAuth) +} + +func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + msg := &gloastypes.RequestAuthV1{ + Data: []byte(testBuilderURL), + Slot: 1234, + } + + // Marshal to SSZ. + encoded, err := msg.MarshalSSZ() + require.NoError(t, err) + require.NotEmpty(t, encoded) + + // Unmarshal from SSZ into a fresh value. + decoded := &gloastypes.RequestAuthV1{} + require.NoError(t, decoded.UnmarshalSSZ(encoded)) + + // The decoded value must match the original. + require.Equal(t, msg.Data, decoded.Data) + require.Equal(t, msg.Slot, decoded.Slot) + + // Hash tree roots must match across the round trip. + origRoot, err := msg.HashTreeRoot() + require.NoError(t, err) + decodedRoot, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, origRoot, decodedRoot) + + // Sign the decoded message and verify the signature is valid. + domain := signer.ComputeDomain(gloas.DomainRequestAuth, genesisForkVersion, phase0.Root{}) + sig, err := validator.SignWithDomain(decodedRoot, domain) + require.NoError(t, err) + + signed := &gloastypes.SignedRequestAuthV1{ + Message: decoded, + Signature: sig, + } + require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) +} + +func TestVerifyRequestAuth_RoundTrip(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} // mainnet-style zero version + + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 1234, genesisForkVersion) + + require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) +} + +func TestVerifyRequestAuth_WrongValidatorPubkey(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + other, err := signer.NewBLSSigner(otherPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 42, genesisForkVersion) + + err = gloas.VerifyRequestAuth(signed, other.PublicKey(), genesisForkVersion) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_TamperedSlot(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 100, genesisForkVersion) + signed.Message.Slot = 101 // tamper + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_TamperedBuilderURL(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 7, genesisForkVersion) + signed.Message.Data = []byte(otherBuilderURL) // tamper + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_WrongGenesisForkVersion(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + signingFork := phase0.Version{0x00, 0x00, 0x00, 0x00} + verifyFork := phase0.Version{0x90, 0x00, 0x00, 0x69} // e.g. Sepolia-style + + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 7, signingFork) + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), verifyFork) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_NilSigned(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + err = gloas.VerifyRequestAuth(nil, validator.PublicKey(), phase0.Version{}) + require.ErrorIs(t, err, gloas.ErrNilSignedRequestAuth) +} + +func TestVerifyRequestAuth_NilMessage(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + signed := &gloastypes.SignedRequestAuthV1{ + Message: nil, + Signature: phase0.BLSSignature{}, + } + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) + require.ErrorIs(t, err, gloas.ErrNilRequestAuthMessage) +} + +func TestVerifyRequestAuth_GarbageSignature(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + signed := &gloastypes.SignedRequestAuthV1{ + Message: &gloastypes.RequestAuthV1{ + Data: []byte(testBuilderURL), + Slot: 1, + }, + // All-zero signature is not a valid BLS signature. + Signature: phase0.BLSSignature{}, + } + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) + + // Sanity check: the error message hasn't drifted (loosely). + require.True(t, strings.Contains(err.Error(), "signature")) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1.go new file mode 100644 index 00000000..2361d48a --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + + "github.com/goccy/go-yaml" +) + +// BuilderPreferencesRequestV1 is the body submitted to a builder via the +// submitBuilderPreferences API. The Auth.Message.Data identifies the +// intended builder so the builder can reject preferences that were not +// destined for it. +type BuilderPreferencesRequestV1 struct { + Preferences *BuilderPreferencesV1 + Auth *SignedRequestAuthV1 +} + +// String returns a string version of the structure. +func (b *BuilderPreferencesRequestV1) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go new file mode 100644 index 00000000..68c207d7 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go @@ -0,0 +1,54 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + + "github.com/pkg/errors" +) + +// builderPreferencesRequestV1JSON is the spec representation of the struct. +type builderPreferencesRequestV1JSON struct { + Preferences *BuilderPreferencesV1 `json:"preferences"` + Auth *SignedRequestAuthV1 `json:"auth"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderPreferencesRequestV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderPreferencesRequestV1JSON{ + Preferences: b.Preferences, + Auth: b.Auth, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderPreferencesRequestV1) UnmarshalJSON(input []byte) error { + var data builderPreferencesRequestV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Preferences == nil { + return errors.New("preferences missing") + } + b.Preferences = data.Preferences + + if data.Auth == nil { + return errors.New("auth missing") + } + b.Auth = data.Auth + + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go new file mode 100644 index 00000000..d6a6b12e --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go @@ -0,0 +1,136 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 73051b06cc63fbd730494da90db480932cf6120fd2b1e8914804a555bdb3a416 +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *BuilderPreferencesRequestV1 to SSZ-encoded bytes. +func (t *BuilderPreferencesRequestV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPreferencesRequestV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPreferencesRequestV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPreferencesRequestV1) + } + dstlen := len(dst) + { // Static Field #0 'Preferences' + t := t.Preferences + if t == nil { + t = new(BuilderPreferencesV1) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Preferences") + } + } + // Offset Field #1 'Auth' + dst = append(dst, 0, 0, 0, 0) + { // Dynamic Field #1 'Auth' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Auth + if t == nil { + t = new(SignedRequestAuthV1) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Auth") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPreferencesRequestV1 from SSZ-encoded bytes. +func (t *BuilderPreferencesRequestV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + { // Field #0 'Preferences' (static) + buf := buf[0:8] + if t.Preferences == nil { + t.Preferences = new(BuilderPreferencesV1) + } + if err = t.Preferences.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Preferences") + } + } + // Field #1 'Auth' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset1 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset1, 12), "Auth:o") + } + { // Field #1 'Auth' (dynamic) + buf := buf[offset1:] + if t.Auth == nil { + t.Auth = new(SignedRequestAuthV1) + } + if err = t.Auth.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Auth") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPreferencesRequestV1. +func (t *BuilderPreferencesRequestV1) SizeSSZ() (size int) { + if t == nil { + t = new(BuilderPreferencesRequestV1) + } + // Field #0 'Preferences' static (8 bytes) + // Field #1 'Auth' offset (4 bytes) + size += 12 + { // Dynamic field #1 'Auth' + size += t.Auth.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPreferencesRequestV1. +func (t *BuilderPreferencesRequestV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPreferencesRequestV1 using the given hash walker. +func (t *BuilderPreferencesRequestV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPreferencesRequestV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Preferences' + t := t.Preferences + if t == nil { + t = new(BuilderPreferencesV1) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Preferences") + } + } + { // Field #1 'Auth' + t := t.Auth + if t == nil { + t = new(SignedRequestAuthV1) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Auth") + } + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go new file mode 100644 index 00000000..4b401904 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderPreferencesRequestV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderPreferencesRequestV1JSON{ + Preferences: b.Preferences, + Auth: b.Auth, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderPreferencesRequestV1) UnmarshalYAML(input []byte) error { + var data builderPreferencesRequestV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1.go b/pkg/builderapi/gloas/types/builderpreferencesv1.go new file mode 100644 index 00000000..81cc63a4 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderPreferencesV1 communicates a proposer's per-builder preferences to a +// specific builder ahead of the bid request. A value of zero for +// MaxExecutionPayment indicates the proposer does not accept any execution +// layer payments from this builder, requiring the use of the on-chain +// trustless payment mechanism instead. +type BuilderPreferencesV1 struct { + MaxExecutionPayment phase0.Gwei +} + +// String returns a string version of the structure. +func (b *BuilderPreferencesV1) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1_json.go b/pkg/builderapi/gloas/types/builderpreferencesv1_json.go new file mode 100644 index 00000000..bb28cccf --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1_json.go @@ -0,0 +1,54 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// builderPreferencesV1JSON is the spec representation of the struct. +type builderPreferencesV1JSON struct { + MaxExecutionPayment string `json:"max_execution_payment"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderPreferencesV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderPreferencesV1JSON{ + MaxExecutionPayment: fmt.Sprintf("%d", b.MaxExecutionPayment), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderPreferencesV1) UnmarshalJSON(input []byte) error { + var data builderPreferencesV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.MaxExecutionPayment == "" { + return errors.New("max execution payment missing") + } + maxExecutionPayment, err := strconv.ParseUint(data.MaxExecutionPayment, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid max execution payment") + } + b.MaxExecutionPayment = phase0.Gwei(maxExecutionPayment) + + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go b/pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go new file mode 100644 index 00000000..6dfcc229 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go @@ -0,0 +1,75 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: b88f4577f809d21495b23ade5b57ee353a8cb5f9310f34f1e02e865c3ed09955 +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *BuilderPreferencesV1 to SSZ-encoded bytes. +func (t *BuilderPreferencesV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPreferencesV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPreferencesV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPreferencesV1) + } + { // Static Field #0 'MaxExecutionPayment' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.MaxExecutionPayment)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPreferencesV1 from SSZ-encoded bytes. +func (t *BuilderPreferencesV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 8 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 8) + } + { // Field #0 'MaxExecutionPayment' (static) + buf := buf[0:8] + t.MaxExecutionPayment = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPreferencesV1. +func (t *BuilderPreferencesV1) SizeSSZ() (size int) { + return 8 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPreferencesV1. +func (t *BuilderPreferencesV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPreferencesV1 using the given hash walker. +func (t *BuilderPreferencesV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPreferencesV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'MaxExecutionPayment' + hh.PutUint64(uint64(t.MaxExecutionPayment)) + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go b/pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go new file mode 100644 index 00000000..e134ef89 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderPreferencesV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderPreferencesV1JSON{ + MaxExecutionPayment: fmt.Sprintf("%d", b.MaxExecutionPayment), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderPreferencesV1) UnmarshalYAML(input []byte) error { + var data builderPreferencesV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/gloas/types/generate.go b/pkg/builderapi/gloas/types/generate.go new file mode 100644 index 00000000..d01517f8 --- /dev/null +++ b/pkg/builderapi/gloas/types/generate.go @@ -0,0 +1,17 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +//go:generate rm -f *_ssz.go +//go:generate go tool dynssz-gen -config generate.yaml diff --git a/pkg/builderapi/gloas/types/generate.yaml b/pkg/builderapi/gloas/types/generate.yaml new file mode 100644 index 00000000..1bf46482 --- /dev/null +++ b/pkg/builderapi/gloas/types/generate.yaml @@ -0,0 +1,13 @@ +package: . +legacy: true +without-dynamic-expressions: true + +types: + - name: RequestAuthV1 + output: requestauthv1_ssz.go + - name: SignedRequestAuthV1 + output: signedrequestauthv1_ssz.go + - name: BuilderPreferencesV1 + output: builderpreferencesv1_ssz.go + - name: BuilderPreferencesRequestV1 + output: builderpreferencesrequestv1_ssz.go diff --git a/pkg/builderapi/gloas/types/requestauthv1.go b/pkg/builderapi/gloas/types/requestauthv1.go new file mode 100644 index 00000000..92a786fe --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1.go @@ -0,0 +1,46 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package types holds the Gloas-fork Builder API request/response types +// (RequestAuth, SignedRequestAuth, BuilderPreferences, BuilderPreferencesRequest). +// They were vendored from go-builder-client so buildoor owns them directly, and +// use buildoor's ethpandaops/go-eth2-client phase0 types. The _ssz.go files are +// generated; see generate.go. +package types + +import ( + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// RequestAuthV1 is used by a proposer to authenticate a bid request to a specific +// builder. The proposer signs over a generic data field (set to the builder's +// URL) and the slot to prevent other builders from replaying the request to +// learn the builder's valuation, and to prevent DOS attempts from competing +// parties. +type RequestAuthV1 struct { + Data []byte `ssz-max:"4096"` + Slot phase0.Slot +} + +// String returns a string version of the structure. +func (r *RequestAuthV1) String() string { + data, err := yaml.Marshal(r) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/requestauthv1_json.go b/pkg/builderapi/gloas/types/requestauthv1_json.go new file mode 100644 index 00000000..6e03ed4b --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1_json.go @@ -0,0 +1,73 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// MaxDataSize is the maximum number of bytes in the request auth data field. +const MaxDataSize = 4096 + +// requestAuthV1JSON is the spec representation of the struct. +type requestAuthV1JSON struct { + Data string `json:"data"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (r *RequestAuthV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&requestAuthV1JSON{ + Data: fmt.Sprintf("%#x", r.Data), + Slot: fmt.Sprintf("%d", r.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (r *RequestAuthV1) UnmarshalJSON(input []byte) error { + var data requestAuthV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Data == "" { + return errors.New("data missing") + } + dataBytes, err := hex.DecodeString(strings.TrimPrefix(data.Data, "0x")) + if err != nil { + return errors.Wrap(err, "invalid data") + } + if len(dataBytes) > MaxDataSize { + return errors.New("data too long") + } + r.Data = dataBytes + + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + r.Slot = phase0.Slot(slot) + + return nil +} diff --git a/pkg/builderapi/gloas/types/requestauthv1_ssz.go b/pkg/builderapi/gloas/types/requestauthv1_ssz.go new file mode 100644 index 00000000..d8b8cb9b --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1_ssz.go @@ -0,0 +1,117 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 597218dd9b058462ddcddfb47bf27558f66616c2efb1e93c89d8f2eb8d6e3421 +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *RequestAuthV1 to SSZ-encoded bytes. +func (t *RequestAuthV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *RequestAuthV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *RequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(RequestAuthV1) + } + dstlen := len(dst) + // Offset Field #0 'Data' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Dynamic Field #0 'Data' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + vlen := len(t.Data) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Data") + } + dst = append(dst, t.Data[:]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *RequestAuthV1 from SSZ-encoded bytes. +func (t *RequestAuthV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + // Field #0 'Data' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 12), "Data:o") + } + { // Field #1 'Slot' (static) + buf := buf[4:12] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #0 'Data' (dynamic) + buf := buf[offset0:] + if len(buf) > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(len(buf), 4096), "Data") + } + t.Data = sszutils.ExpandSlice(t.Data, len(buf)) + copy(t.Data[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *RequestAuthV1. +func (t *RequestAuthV1) SizeSSZ() (size int) { + if t == nil { + t = new(RequestAuthV1) + } + // Field #0 'Data' offset (4 bytes) + // Field #1 'Slot' static (8 bytes) + size += 12 + { // Dynamic field #0 'Data' + size += len(t.Data) + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *RequestAuthV1. +func (t *RequestAuthV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *RequestAuthV1 using the given hash walker. +func (t *RequestAuthV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(RequestAuthV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Data' + vlen := uint64(len(t.Data)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Data") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + hh.AppendBytes32(t.Data[:]) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 1)) + } + { // Field #1 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/requestauthv1_yaml.go b/pkg/builderapi/gloas/types/requestauthv1_yaml.go new file mode 100644 index 00000000..16999c48 --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (r *RequestAuthV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&requestAuthV1JSON{ + Data: fmt.Sprintf("%#x", r.Data), + Slot: fmt.Sprintf("%d", r.Slot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (r *RequestAuthV1) UnmarshalYAML(input []byte) error { + var data requestAuthV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return r.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1.go b/pkg/builderapi/gloas/types/signedrequestauthv1.go new file mode 100644 index 00000000..8ff20dba --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedRequestAuthV1 wraps a RequestAuthV1 with the proposer's signature over the +// hash tree root of the message. It is sent in the body of getExecutionPayloadBid +// and submitBuilderPreferences requests so that builders can authenticate the +// requesting validator. +type SignedRequestAuthV1 struct { + Message *RequestAuthV1 + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedRequestAuthV1) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1_json.go b/pkg/builderapi/gloas/types/signedrequestauthv1_json.go new file mode 100644 index 00000000..13defc79 --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1_json.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// signedRequestAuthV1JSON is the spec representation of the struct. +type signedRequestAuthV1JSON struct { + Message *RequestAuthV1 `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedRequestAuthV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedRequestAuthV1JSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedRequestAuthV1) UnmarshalJSON(input []byte) error { + var data signedRequestAuthV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go b/pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go new file mode 100644 index 00000000..94ac7a5e --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go @@ -0,0 +1,119 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 90a5f069bf853b5db64738807deada0a571106725fdeb43e4414702f018bc23f +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *SignedRequestAuthV1 to SSZ-encoded bytes. +func (t *SignedRequestAuthV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedRequestAuthV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedRequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedRequestAuthV1) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(RequestAuthV1) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedRequestAuthV1 from SSZ-encoded bytes. +func (t *SignedRequestAuthV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(RequestAuthV1) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedRequestAuthV1. +func (t *SignedRequestAuthV1) SizeSSZ() (size int) { + if t == nil { + t = new(SignedRequestAuthV1) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedRequestAuthV1. +func (t *SignedRequestAuthV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedRequestAuthV1 using the given hash walker. +func (t *SignedRequestAuthV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedRequestAuthV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(RequestAuthV1) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go b/pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go new file mode 100644 index 00000000..5cd4cdf8 --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedRequestAuthV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedRequestAuthV1JSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedRequestAuthV1) UnmarshalYAML(input []byte) error { + var data signedRequestAuthV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 2d2d18ca..52fbc306 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -10,29 +10,44 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "io" + "math/big" "net/http" "strconv" "strings" "sync/atomic" "time" + "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/gorilla/mux" "github.com/sirupsen/logrus" "github.com/ethpandaops/buildoor/pkg/builder" "github.com/ethpandaops/buildoor/pkg/builderapi/fulu" + gloasauth "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" "github.com/ethpandaops/buildoor/pkg/builderapi/validators" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/config" "github.com/ethpandaops/buildoor/pkg/db" + "github.com/ethpandaops/buildoor/pkg/proposerpreferences" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" "github.com/ethpandaops/buildoor/pkg/signer" ) +// domainBeaconBuilder is DOMAIN_BEACON_BUILDER from the Gloas consensus spec, +// used to sign ExecutionPayloadBid and ExecutionPayloadEnvelope messages. +var domainBeaconBuilder = phase0.DomainType{0x0B, 0x00, 0x00, 0x00} + // PayloadCacheProvider provides access to the payload cache (e.g. *builder.Service). // Used so tests can inject a mock without full builder deps. type PayloadCacheProvider interface { @@ -65,19 +80,24 @@ type RequestStats struct { type Server struct { cfg *config.BuilderAPIConfig log *logrus.Logger - builderSvc PayloadCacheProvider // optional: for buildoor debug APIs and Fulu getHeader/submitBlindedBlockV2 - validatorsStore *validators.Store // in-memory validator registrations - blsSigner *signer.BLSSigner // optional: for signing Fulu builder bids (getHeader) - fuluPublisher FuluBlockPublisher // optional: for publishing unblinded blocks (submitBlindedBlockV2) - eventBroadcaster EventBroadcaster // optional: for broadcasting API events to WebUI - bidsWonStore *BidsWonStore // in-memory store of successfully delivered blocks - stateDB *db.Database // optional: persistent won-block store (may be nil/disabled) - enabled atomic.Bool // runtime toggle for enabling/disabling the builder API - headersRequested atomic.Uint64 // count of getHeader requests received - blocksPublished atomic.Uint64 // count of successfully published blocks - genesisForkVersion phase0.Version // genesis fork version for builder domain (mev-boost-relay style) - forkVersion phase0.Version // current fork version for chain-specific verification - genesisValidatorsRoot phase0.Root // genesis validators root for chain-specific verification + builderSvc PayloadCacheProvider // optional: for buildoor debug APIs and Fulu getHeader/submitBlindedBlockV2 + validatorsStore *validators.Store // in-memory validator registrations + blsSigner *signer.BLSSigner // optional: for signing Fulu builder bids (getHeader) + fuluPublisher FuluBlockPublisher // optional: for publishing unblinded blocks (submitBlindedBlockV2) + clClient *beacon.Client // optional: beacon client used to publish Gloas execution payload envelopes + eventBroadcaster EventBroadcaster // optional: for broadcasting API events to WebUI + bidsWonStore *BidsWonStore // in-memory store of successfully delivered blocks + builderPrefsStore *BuilderPreferencesStore // latest per-validator builder preferences (max_execution_payment) + propPrefsCache *proposerpreferences.Cache // optional: per-slot proposer preferences for Gloas bid construction + chainSvc chain.Service // optional: used to verify builder is active before serving Gloas bids + builderIndex atomic.Uint64 // builder index used in Gloas bids; set after lifecycle registration + enabled atomic.Bool // runtime toggle for enabling/disabling the builder API + headersRequested atomic.Uint64 // count of getHeader requests received + blocksPublished atomic.Uint64 // count of successfully published blocks + genesisForkVersion phase0.Version // genesis fork version for builder domain (mev-boost-relay style) + forkVersion phase0.Version // current fork version for chain-specific verification + genesisValidatorsRoot phase0.Root // genesis validators root for chain-specific verification + stateDB *db.Database // optional: persistent won-block store (may be nil/disabled) } // NewServer creates a new server. builderSvc may be nil; if set, buildoor-specific @@ -98,6 +118,7 @@ func NewServer(cfg *config.BuilderAPIConfig, log *logrus.Logger, builderSvc Payl validatorsStore: store, blsSigner: blsSigner, bidsWonStore: NewBidsWonStore(1000), + builderPrefsStore: NewBuilderPreferencesStore(), genesisForkVersion: genesisForkVersion, forkVersion: forkVersion, genesisValidatorsRoot: genesisValidatorsRoot, @@ -125,16 +146,46 @@ func (s *Server) SetFuluPublisher(p FuluBlockPublisher) { s.fuluPublisher = p } +// SetCLClient wires the beacon client used to publish Gloas execution payload +// envelopes after a SignedBeaconBlock is submitted via the Builder API. +func (s *Server) SetCLClient(c *beacon.Client) { + s.clClient = c +} + // SetEventBroadcaster sets the optional event broadcaster for WebUI events. func (s *Server) SetEventBroadcaster(b EventBroadcaster) { s.eventBroadcaster = b } +// SetProposerPreferencesCache wires the proposer preferences cache used to +// resolve fee recipient and gas limit when building Gloas execution payload bids. +func (s *Server) SetProposerPreferencesCache(cache *proposerpreferences.Cache) { + s.propPrefsCache = cache +} + +// SetChainService wires the chain service used to verify the builder is active +// (deposit finalized, not exited) before serving Gloas execution payload bids. +func (s *Server) SetChainService(c chain.Service) { + s.chainSvc = c +} + +// SetBuilderIndex sets the on-chain builder index inserted into Gloas bids. +// Called from the lifecycle manager once registration is observed. +func (s *Server) SetBuilderIndex(index uint64) { + s.builderIndex.Store(index) +} + // GetBidsWonStore returns the bids won store. func (s *Server) GetBidsWonStore() *BidsWonStore { return s.bidsWonStore } +// GetBuilderPreferencesStore returns the store of latest per-validator builder +// preferences submitted via the submitBuilderPreferences API. +func (s *Server) GetBuilderPreferencesStore() *BuilderPreferencesStore { + return s.builderPrefsStore +} + // GetRequestStats returns the current request counters. func (s *Server) GetRequestStats() RequestStats { return RequestStats{ @@ -157,6 +208,20 @@ func (s *Server) RegisterRoutes(router *mux.Router) { builderAPIv2 := router.PathPrefix("/eth/v2/builder").Subrouter() builderAPIv2.HandleFunc("/blinded_blocks", s.handleSubmitBlindedBlockV2).Methods(http.MethodPost) + // --- Builder API (Gloas) --- + // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/execution_payload_bid.yaml + builderAPI.HandleFunc( + "/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}", + s.handleGetExecutionPayloadBid, + ).Methods(http.MethodPost) + // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/beacon_block.yaml + builderAPI.HandleFunc("/beacon_block", s.handleSubmitSignedBeaconBlock).Methods(http.MethodPost) + // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/builder_preferences.yaml + builderAPI.HandleFunc( + "/builder_preferences/{validator_pubkey}", + s.handleSubmitBuilderPreferences, + ).Methods(http.MethodPost) + // --- Buildoor API (debug / tooling) --- buildoorAPI := router.PathPrefix("/buildoor/v1").Subrouter() buildoorAPI.HandleFunc("/payloads/{slot}", s.handleGetPayloadBySlot).Methods(http.MethodGet) @@ -455,6 +520,554 @@ func (s *Server) handleGetHeader(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +// GetExecutionPayloadBidResponse is the JSON envelope returned by +// POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}. +type GetExecutionPayloadBidResponse struct { + Version string `json:"version"` + Data *gloas.SignedExecutionPayloadBid `json:"data"` +} + +// handleGetExecutionPayloadBid handles +// POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}. +// +// Looks up the cached payload for the requested slot, validates the supplied +// parent_hash and parent_root against it, then constructs and signs a Gloas +// SignedExecutionPayloadBid using the proposer's fee recipient from the +// ProposerPreferences cache. Returns 204 if no payload is cached, 400 if the +// inputs do not match the cached payload or proposer preferences are missing. +// +// If the request body contains a SignedRequestAuthV1, it is validated: +// - auth.message.slot must match the requested slot +// - auth.message.builder_url must match cfg.BuilderURL (if configured) +// - BLS signature must verify against the proposer_pubkey path parameter +func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Request) { + log := s.log.WithField("path", "/eth/v1/builder/execution_payload_bid/...") + + if !s.enabled.Load() || s.builderSvc == nil || s.blsSigner == nil { + log.Warn("getExecutionPayloadBid: returning 204 — builder service disabled or signer/payload cache unavailable") + w.WriteHeader(http.StatusNoContent) + return + } + + if s.chainSvc != nil && !chain.IsBuilderActive(s.chainSvc.GetBuilderByPubkey(s.blsSigner.PublicKey()), uint64(s.chainSvc.GetFinalizedEpoch())) { + log.Warn("getExecutionPayloadBid: returning 204 — builder not active on chain") + w.WriteHeader(http.StatusNoContent) + return + } + + if s.propPrefsCache == nil { + log.Warn("getExecutionPayloadBid: proposer preferences cache not configured") + writeValidatorError(w, http.StatusInternalServerError, "proposer preferences cache not configured") + return + } + + vars := mux.Vars(r) + slotStr := vars["slot"] + parentHashStr := vars["parent_hash"] + parentRootStr := vars["parent_root"] + proposerPubkeyStr := vars["proposer_pubkey"] + + log = log.WithFields(logrus.Fields{ + "slot": slotStr, + "parent_hash": parentHashStr, + "parent_root": parentRootStr, + "proposer_pubkey": proposerPubkeyStr, + }) + log.Debug("getExecutionPayloadBid request received") + + pubkeyBytes, hexErr := hex.DecodeString(trimHex(proposerPubkeyStr)) + if hexErr != nil || len(pubkeyBytes) != 48 { + log.WithError(hexErr).Warn("getExecutionPayloadBid: invalid proposer_pubkey for auth verification") + writeValidatorError(w, http.StatusBadRequest, "invalid proposer_pubkey: must be 48 bytes hex") + return + } + var proposerPubkey phase0.BLSPubKey + copy(proposerPubkey[:], pubkeyBytes) + + slotU64, err := strconv.ParseUint(slotStr, 10, 64) + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: invalid slot") + writeValidatorError(w, http.StatusBadRequest, "invalid slot: must be a number") + return + } + slot := phase0.Slot(slotU64) + + // Resolve the Gloas fork version once for bid signing (DomainBeaconBuilder is + // chain-fork bound). Request auth is NOT signed with this: per the Gloas + // builder-specs, RequestAuth is signed with compute_domain(DOMAIN_REQUEST_AUTH) + // using the genesis fork version and a zero genesis_validators_root — an + // application-space domain that mirrors DomainApplicationBuilder. So auth is + // verified below with s.genesisForkVersion, not gloasForkVersion. + gloasForkVersion := s.forkVersion + if s.chainSvc != nil { + if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { + gloasForkVersion = *cs.GloasForkVersion + } + } + + // Parse and validate SignedRequestAuth from the request body. + // Auth is always verified when present; s.cfg.RequireRequestAuth controls whether + // absence is an error. + var authBody []byte + if r.ContentLength > 0 { + var readErr error + authBody, readErr = io.ReadAll(r.Body) + if readErr != nil { + log.WithError(readErr).Warn("getExecutionPayloadBid: failed to read request body") + writeValidatorError(w, http.StatusBadRequest, "failed to read request body") + return + } + } + if len(authBody) > 0 { + signedAuth, parseErr := parseSignedRequestAuth(authBody, r.Header.Get("Content-Type")) + if parseErr != nil { + code := http.StatusBadRequest + if errors.Is(parseErr, errUnsupportedContentType) { + code = http.StatusUnsupportedMediaType + } + log.WithError(parseErr).Warn("getExecutionPayloadBid: invalid SignedRequestAuth body") + writeValidatorError(w, code, "invalid SignedRequestAuthV1: "+parseErr.Error()) + return + } + if signedAuth.Message == nil { + log.Warn("getExecutionPayloadBid: SignedRequestAuth missing message") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: message is null") + return + } + if phase0.Slot(signedAuth.Message.Slot) != slot { + log.WithFields(logrus.Fields{ + "auth_slot": signedAuth.Message.Slot, + "request_slot": slot, + }).Warn("getExecutionPayloadBid: SignedRequestAuth slot mismatch") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.slot does not match the requested slot") + return + } + if s.cfg.BuilderURL != "" && string(signedAuth.Message.Data) != s.cfg.BuilderURL { + log.WithFields(logrus.Fields{ + "auth_url": string(signedAuth.Message.Data), + "builder_url": s.cfg.BuilderURL, + }).Warn("getExecutionPayloadBid: SignedRequestAuth data (builder_url) mismatch") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.data does not match this builder's URL") + return + } + + if authErr := gloasauth.VerifyRequestAuth(signedAuth, proposerPubkey, s.genesisForkVersion); authErr != nil { + log.WithError(authErr).Warn("getExecutionPayloadBid: SignedRequestAuth signature verification failed") + writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") + return + } + log.Info("getExecutionPayloadBid: SignedRequestAuth verified") + } else if s.cfg.RequireRequestAuth { + log.Warn("getExecutionPayloadBid: missing required SignedRequestAuth") + writeValidatorError(w, http.StatusUnauthorized, "missing SignedRequestAuthV1: this builder requires authenticated requests") + return + } + + parentHashBytes, err := hex.DecodeString(trimHex(parentHashStr)) + if err != nil || len(parentHashBytes) != 32 { + log.WithError(err).Warn("getExecutionPayloadBid: invalid parent_hash") + writeValidatorError(w, http.StatusBadRequest, "invalid parent_hash: must be 32 bytes hex") + return + } + var parentHash phase0.Hash32 + copy(parentHash[:], parentHashBytes) + + parentRootBytes, err := hex.DecodeString(trimHex(parentRootStr)) + if err != nil || len(parentRootBytes) != 32 { + log.WithError(err).Warn("getExecutionPayloadBid: invalid parent_root") + writeValidatorError(w, http.StatusBadRequest, "invalid parent_root: must be 32 bytes hex") + return + } + var parentRoot phase0.Root + copy(parentRoot[:], parentRootBytes) + + event := s.builderSvc.GetPayloadCache().Get(slot) + if event == nil { + log.Info("getExecutionPayloadBid: returning 204 — no cached payload for slot") + w.WriteHeader(http.StatusNoContent) + return + } + + if event.ParentBlockHash != parentHash { + log.WithFields(logrus.Fields{ + "request_parent_hash": "0x" + hex.EncodeToString(parentHash[:]), + "cached_parent_hash": "0x" + hex.EncodeToString(event.ParentBlockHash[:]), + }).Info("getExecutionPayloadBid: 400 — parent_hash does not match cached payload") + writeValidatorError(w, http.StatusBadRequest, "parent_hash does not match cached payload") + return + } + + if event.ParentBlockRoot != parentRoot { + log.WithFields(logrus.Fields{ + "request_parent_root": "0x" + hex.EncodeToString(parentRoot[:]), + "cached_parent_root": "0x" + hex.EncodeToString(event.ParentBlockRoot[:]), + }).Info("getExecutionPayloadBid: 400 — parent_root does not match cached payload") + writeValidatorError(w, http.StatusBadRequest, "parent_root does not match cached payload") + return + } + + signedPrefs, ok := s.propPrefsCache.Get(slot) + if !ok || signedPrefs == nil || signedPrefs.Message == nil { + log.Info("getExecutionPayloadBid: 400 — no proposer preferences cached for slot") + writeValidatorError(w, http.StatusBadRequest, "no proposer preferences cached for slot") + return + } + prefs := signedPrefs.Message + + execRequests := &electra.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + } + if len(event.ExecutionRequests) > 0 { + parsed, parseErr := fulu.ParseExecutionRequests(event.ExecutionRequests) + if parseErr != nil { + log.WithError(parseErr).Warn("getExecutionPayloadBid: failed to parse execution requests") + writeValidatorError(w, http.StatusInternalServerError, "failed to parse execution requests") + return + } + execRequests = parsed + } + execRequestsRoot, err := execRequests.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: failed to compute execution requests root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute execution requests root") + return + } + + blockValueGwei := new(big.Int).Div(event.BlockValue, big.NewInt(1e9)).Uint64() + + // Split the post-subsidy block value between the execution-layer payment and the + // trustless on-chain payment (Value). max_execution_payment caps how much the + // proposer accepts directly from the builder as an execution payment; it defaults + // to 0 when the proposer never submitted preferences, per the Gloas spec (no + // execution payment allowed in that case). Anything above the cap is paid + // trustlessly on-chain via Value. + valueAfterSubsidy := phase0.Gwei(blockValueGwei + s.cfg.GloasBuilderApiSubsidy) + maxExecutionPayment := s.builderPrefsStore.GetOrDefault(proposerPubkey) + executionPayment := min(valueAfterSubsidy, maxExecutionPayment) + value := valueAfterSubsidy - executionPayment + + bid := &gloas.ExecutionPayloadBid{ + ParentBlockHash: event.ParentBlockHash, + ParentBlockRoot: event.ParentBlockRoot, + BlockHash: event.BlockHash, + PrevRandao: event.PrevRandao, + FeeRecipient: prefs.FeeRecipient, + GasLimit: event.GasLimit, + BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), + Slot: slot, + Value: value, + ExecutionPayment: executionPayment, + BlobKZGCommitments: []deneb.KZGCommitment{}, + ExecutionRequestsRoot: execRequestsRoot, + } + if event.BlobsBundle != nil { + bid.BlobKZGCommitments = make([]deneb.KZGCommitment, len(event.BlobsBundle.Commitments)) + for i, c := range event.BlobsBundle.Commitments { + copy(bid.BlobKZGCommitments[i][:], c) + } + } + + bidRoot, err := bid.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: failed to compute bid hash tree root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute bid root") + return + } + var root phase0.Root + copy(root[:], bidRoot[:]) + + domain := signer.ComputeDomain(domainBeaconBuilder, gloasForkVersion, s.genesisValidatorsRoot) + sig, err := s.blsSigner.SignWithDomain(root, domain) + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: failed to sign bid") + writeValidatorError(w, http.StatusInternalServerError, "failed to sign bid") + return + } + + signedBid := &gloas.SignedExecutionPayloadBid{ + Message: bid, + Signature: sig, + } + + log.WithFields(logrus.Fields{ + "block_hash": "0x" + hex.EncodeToString(event.BlockHash[:]), + "builder_index": bid.BuilderIndex, + "fee_recipient": prefs.FeeRecipient.String(), + "gas_limit": bid.GasLimit, + }).Info("getExecutionPayloadBid: delivered Gloas SignedExecutionPayloadBid") + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(GetExecutionPayloadBidResponse{ + Version: "gloas", + Data: signedBid, + }) +} + +// handleSubmitSignedBeaconBlock handles POST /eth/v1/builder/beacon_block. +// +// The proposer submits a full Gloas SignedBeaconBlock that binds them to the +// builder's bid. If the builder still holds the payload referenced by the +// bid's block_hash, it constructs the corresponding SignedExecutionPayloadEnvelope +// and publishes it (along with blobs / KZG cell proofs) to the beacon node. +// +// Returns 202 on success, 400 on a malformed block or missing payload, +// 415 on wrong Content-Type, 500 on internal errors, and 503 if the server is +// not fully configured. +func (s *Server) handleSubmitSignedBeaconBlock(w http.ResponseWriter, r *http.Request) { + log := s.log.WithField("path", "/eth/v1/builder/beacon_block") + + if !s.enabled.Load() || s.builderSvc == nil || s.blsSigner == nil || s.clClient == nil { + log.Warn("submitSignedBeaconBlock: 503 — builder not fully configured (disabled, payload cache, signer, or CL client missing)") + writeValidatorError(w, http.StatusServiceUnavailable, "builder not ready") + return + } + + if r.Header.Get("Content-Type") != "application/json" { + log.WithField("content_type", r.Header.Get("Content-Type")).Warn("submitSignedBeaconBlock: rejected — Content-Type must be application/json") + writeValidatorError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to read body") + writeValidatorError(w, http.StatusBadRequest, "failed to read body") + return + } + + var block gloas.SignedBeaconBlock + if err := json.Unmarshal(body, &block); err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: invalid JSON body") + writeValidatorError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if block.Message == nil || block.Message.Body == nil || + block.Message.Body.SignedExecutionPayloadBid == nil || + block.Message.Body.SignedExecutionPayloadBid.Message == nil { + log.Warn("submitSignedBeaconBlock: missing signed_execution_payload_bid in block body") + writeValidatorError(w, http.StatusBadRequest, "missing signed_execution_payload_bid in block body") + return + } + + bid := block.Message.Body.SignedExecutionPayloadBid.Message + blockHashHex := "0x" + hex.EncodeToString(bid.BlockHash[:]) + log = log.WithFields(logrus.Fields{ + "slot": bid.Slot, + "block_hash": blockHashHex, + }) + log.Debug("submitSignedBeaconBlock request received") + + event := s.builderSvc.GetPayloadCache().GetByBlockHash(bid.BlockHash) + if event == nil { + log.Info("submitSignedBeaconBlock: 400 — no cached payload for bid block hash") + writeValidatorError(w, http.StatusBadRequest, "no cached payload for bid block hash") + return + } + + gloasPayload, err := fulu.ExecutionPayloadToGloas(event.Payload) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to convert payload to gloas format") + writeValidatorError(w, http.StatusInternalServerError, "failed to convert payload") + return + } + + execRequests := &electra.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + } + if len(event.ExecutionRequests) > 0 { + parsed, parseErr := fulu.ParseExecutionRequests(event.ExecutionRequests) + if parseErr != nil { + log.WithError(parseErr).Warn("submitSignedBeaconBlock: failed to parse execution requests") + writeValidatorError(w, http.StatusInternalServerError, "failed to parse execution requests") + return + } + execRequests = parsed + } + + beaconBlockRoot, err := block.Message.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to compute beacon block hash tree root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute beacon block root") + return + } + var blockRoot phase0.Root + copy(blockRoot[:], beaconBlockRoot[:]) + + envelope := &gloas.ExecutionPayloadEnvelope{ + Payload: gloasPayload, + ExecutionRequests: execRequests, + BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), + BeaconBlockRoot: blockRoot, + ParentBeaconBlockRoot: block.Message.ParentRoot, + } + + envelopeRoot, err := envelope.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to compute envelope hash tree root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute envelope root") + return + } + var root phase0.Root + copy(root[:], envelopeRoot[:]) + + envForkVersion := s.forkVersion + if s.chainSvc != nil { + if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { + envForkVersion = *cs.GloasForkVersion + } + } + domain := signer.ComputeDomain(domainBeaconBuilder, envForkVersion, s.genesisValidatorsRoot) + sig, err := s.blsSigner.SignWithDomain(root, domain) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to sign envelope") + writeValidatorError(w, http.StatusInternalServerError, "failed to sign envelope") + return + } + + signedEnvelope := &gloas.SignedExecutionPayloadEnvelope{ + Message: envelope, + Signature: sig, + } + + envelopeJSON, err := json.Marshal(signedEnvelope) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to marshal signed envelope") + writeValidatorError(w, http.StatusInternalServerError, "failed to marshal envelope") + return + } + + if err := s.clClient.SubmitProposal(r.Context(), &api.SubmitProposalOpts{ + Proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Blinded: false, + Gloas: &block, + }, + }); err != nil { + log.WithError(err).Error("submitSignedBeaconBlock: failed to broadcast beacon block") + writeValidatorError(w, http.StatusInternalServerError, "failed to broadcast beacon block: "+err.Error()) + return + } + log.Info("submitSignedBeaconBlock: broadcasted beacon block") + + var blobs, kzgProofs [][]byte + if event.BlobsBundle != nil && len(event.BlobsBundle.Blobs) > 0 { + blobs = event.BlobsBundle.Blobs + kzgProofs = event.BlobsBundle.Proofs + } + + if err := s.clClient.SubmitExecutionPayloadEnvelope(r.Context(), envelopeJSON, blobs, kzgProofs); err != nil { + log.WithError(err).Error("submitSignedBeaconBlock: failed to publish execution payload envelope") + writeValidatorError(w, http.StatusInternalServerError, "failed to publish envelope: "+err.Error()) + return + } + + log.WithFields(logrus.Fields{ + "beacon_block_root": "0x" + hex.EncodeToString(blockRoot[:]), + "blobs": len(blobs), + }).Info("submitSignedBeaconBlock: published execution payload envelope") + + w.WriteHeader(http.StatusAccepted) +} + +// handleSubmitBuilderPreferences handles POST /eth/v1/builder/builder_preferences/{validator_pubkey}. +// +// It records the validator's latest max_execution_payment after authenticating +// the request via the embedded SignedRequestAuthV1. Per the Gloas builder-specs, +// the builder MUST verify the auth signature against the validator_pubkey path +// param (401 on failure) and MUST check that auth.message.builder_url matches its +// own URL (400 on failure). The preference is stored only after both checks pass. +// On success it returns 202. +func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.Request) { + log := s.log.WithField("path", "/eth/v1/builder/builder_preferences") + + if !s.enabled.Load() { + log.Warn("submitBuilderPreferences: 503 — builder API disabled") + writeValidatorError(w, http.StatusServiceUnavailable, "builder not ready") + return + } + + // The builder MUST check auth.message.builder_url against its own URL. Without a + // configured URL it cannot perform that mandatory check, so treat it as a server + // misconfiguration (500) rather than a client error. + if s.cfg.BuilderURL == "" { + log.Error("submitBuilderPreferences: 500 — builder URL not configured; cannot verify auth.message.builder_url") + writeValidatorError(w, http.StatusInternalServerError, "builder URL not configured") + return + } + + pubkeyBytes, hexErr := hex.DecodeString(trimHex(mux.Vars(r)["validator_pubkey"])) + if hexErr != nil || len(pubkeyBytes) != 48 { + log.WithError(hexErr).Warn("submitBuilderPreferences: invalid validator_pubkey") + writeValidatorError(w, http.StatusBadRequest, "invalid validator_pubkey: must be 48 bytes hex") + return + } + var validatorPubkey phase0.BLSPubKey + copy(validatorPubkey[:], pubkeyBytes) + + body, err := io.ReadAll(r.Body) + if err != nil { + log.WithError(err).Warn("submitBuilderPreferences: failed to read body") + writeValidatorError(w, http.StatusBadRequest, "failed to read body") + return + } + + req, parseErr := parseBuilderPreferencesRequest(body, r.Header.Get("Content-Type")) + if parseErr != nil { + code := http.StatusBadRequest + if errors.Is(parseErr, errUnsupportedContentType) { + code = http.StatusUnsupportedMediaType + } + log.WithError(parseErr).Warn("submitBuilderPreferences: invalid request body") + writeValidatorError(w, code, "invalid BuilderPreferencesRequestV1: "+parseErr.Error()) + return + } + if req.Preferences == nil { + log.Warn("submitBuilderPreferences: missing preferences") + writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: preferences is null") + return + } + if req.Auth == nil || req.Auth.Message == nil { + log.Warn("submitBuilderPreferences: missing auth") + writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: auth is null") + return + } + + // Check auth.message.data (the builder URL) matches this builder's URL (400 on mismatch). + if string(req.Auth.Message.Data) != s.cfg.BuilderURL { + log.WithFields(logrus.Fields{ + "auth_url": string(req.Auth.Message.Data), + "builder_url": s.cfg.BuilderURL, + }).Warn("submitBuilderPreferences: builder_url mismatch") + writeValidatorError(w, http.StatusBadRequest, "auth.message.data does not match this builder's URL") + return + } + + // Verify the BLS signature against the validator_pubkey path param (401 on failure). + // RequestAuth is signed with DOMAIN_REQUEST_AUTH at the genesis fork version — an + // application-space domain, not chain-fork bound. + if authErr := gloasauth.VerifyRequestAuth(req.Auth, validatorPubkey, s.genesisForkVersion); authErr != nil { + log.WithError(authErr).Warn("submitBuilderPreferences: signature verification failed") + writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") + return + } + + // Auth validated — record the latest preference (overwrites any previous value). + s.builderPrefsStore.Set(validatorPubkey, phase0.Gwei(req.Preferences.MaxExecutionPayment)) + log.WithFields(logrus.Fields{ + "validator_pubkey": "0x" + hex.EncodeToString(validatorPubkey[:]), + "max_execution_payment": uint64(req.Preferences.MaxExecutionPayment), + }).Info("submitBuilderPreferences: stored builder preference") + + w.WriteHeader(http.StatusAccepted) +} + func trimHex(s string) string { if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") { return s[2:] diff --git a/pkg/chain/constants.go b/pkg/chain/constants.go index 6c7dfcdd..ef0585aa 100644 --- a/pkg/chain/constants.go +++ b/pkg/chain/constants.go @@ -4,3 +4,12 @@ package chain const FarFutureEpoch = uint64(0xFFFFFFFFFFFFFFFF) const BuilderIndexFlag uint64 = 1 << 40 + +// IsBuilderActive returns true when a builder's deposit has been finalized and the +// builder has not exited. Pass the result of GetBuilderByPubkey and GetFinalizedEpoch. +func IsBuilderActive(info *BuilderInfo, finalizedEpoch uint64) bool { + if info == nil { + return false + } + return info.DepositEpoch < finalizedEpoch && info.WithdrawableEpoch == FarFutureEpoch +} diff --git a/pkg/config/default.go b/pkg/config/default.go index 239a0e65..d8cf20ae 100644 --- a/pkg/config/default.go +++ b/pkg/config/default.go @@ -11,7 +11,8 @@ func DefaultConfig() *Config { EPBSEnabled: false, // Disabled by default BuilderAPIEnabled: false, // Disabled by default BuilderAPI: BuilderAPIConfig{ - BlockValueSubsidyGwei: 100000, // 100k Gwei + BlockValueSubsidyGwei: 100000, // 100k Gwei + GloasBuilderApiSubsidy: 0, }, DepositAmount: 50000000000, // 50 ETH in Gwei TopupThreshold: 10000000000, // 10 ETH in Gwei diff --git a/pkg/config/types.go b/pkg/config/types.go index 39ef4442..b0c18b39 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -76,8 +76,23 @@ const ( // BuilderAPIConfig defines configuration for the traditional Builder API (pre-ePBS). type BuilderAPIConfig struct { + // BuilderURL is this builder's publicly reachable URL (e.g. "https://builder.example.com"). + // Used to verify the auth.message.data field (set to the builder URL) in + // SignedRequestAuthV1 messages from proposers. If empty, this validation is skipped. + BuilderURL string `yaml:"builder_url" json:"builder_url"` + + // RequireRequestAuth controls whether a SignedRequestAuthV1 body is mandatory on + // getExecutionPayloadBid requests. When true, requests without an auth body are + // rejected with 401. When false (default), auth is optional — but if supplied it + // is always fully validated. + RequireRequestAuth bool `yaml:"require_request_auth" json:"require_request_auth"` + // BlockValueSubsidyGwei is added to the bid value (getHeader) so the proposer sees a higher bid. BlockValueSubsidyGwei uint64 `yaml:"block_value_subsidy_gwei" json:"block_value_subsidy_gwei"` + + // GloasBuilderApiSubsidy is added to the block value (converted to gwei) to form + // bid.ExecutionPayment in Gloas getExecutionPayloadBid calls. + GloasBuilderApiSubsidy uint64 `yaml:"gloas_builder_api_subsidy" json:"gloas_builder_api_subsidy"` } // EPBSConfig defines time-scheduled bidding parameters for ePBS. diff --git a/pkg/epbs/scheduler.go b/pkg/epbs/scheduler.go index d30684d9..bef8e1f9 100644 --- a/pkg/epbs/scheduler.go +++ b/pkg/epbs/scheduler.go @@ -11,7 +11,9 @@ import ( "github.com/sirupsen/logrus" "github.com/ethpandaops/buildoor/pkg/builder" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/rpc/beacon" + "github.com/ethpandaops/buildoor/pkg/signer" ) const ( @@ -48,7 +50,8 @@ type Scheduler struct { payloadStore *PayloadStore payloadCache *builder.PayloadCache service *Service // Reference to parent service for firing events - isBuilderActive func() bool + chainSvc chain.Service + blsSigner *signer.BLSSigner hasProposerPreferences func(phase0.Slot) bool log logrus.FieldLogger @@ -68,7 +71,8 @@ func NewScheduler( payloadStore *PayloadStore, payloadCache *builder.PayloadCache, service *Service, - isBuilderActive func() bool, + chainSvc chain.Service, + blsSigner *signer.BLSSigner, hasProposerPreferences func(phase0.Slot) bool, log logrus.FieldLogger, ) *Scheduler { @@ -82,7 +86,8 @@ func NewScheduler( payloadStore: payloadStore, payloadCache: payloadCache, service: service, - isBuilderActive: isBuilderActive, + chainSvc: chainSvc, + blsSigner: blsSigner, hasProposerPreferences: hasProposerPreferences, slotStates: make(map[phase0.Slot]*SlotState), log: log.WithField("component", "scheduler"), @@ -168,7 +173,7 @@ func (s *Scheduler) ProcessTick(ctx context.Context) { } // Don't bid if the builder is not active on-chain. - if s.isBuilderActive != nil && !s.isBuilderActive() { + if !chain.IsBuilderActive(s.chainSvc.GetBuilderByPubkey(s.blsSigner.PublicKey()), uint64(s.chainSvc.GetFinalizedEpoch())) { // Still check reveals — we may have bids from before deactivation. s.checkSlotForReveal(ctx, currentSlot, now, msIntoSlot) return diff --git a/pkg/epbs/service.go b/pkg/epbs/service.go index 19b7714e..08f83e0a 100644 --- a/pkg/epbs/service.go +++ b/pkg/epbs/service.go @@ -88,6 +88,7 @@ type BidIncludedEvent struct { type Service struct { cfg *builder.EPBSConfig signer *Signer + blsSigner *signer.BLSSigner scheduler *Scheduler bidCreator *BidCreator revealHandler *RevealHandler @@ -140,6 +141,7 @@ func NewService( s := &Service{ cfg: cfg, signer: epbsSigner, + blsSigner: blsSigner, clClient: clClient, chainSvc: chainSvc, builderPubkey: blsSigner.PublicKey(), @@ -242,16 +244,6 @@ func (s *Service) Start(ctx context.Context, builderSvc *builder.Service) error s.builderIndex, s.log, ) - // isBuilderActive checks that the builder's deposit is finalized and it hasn't exited. - isBuilderActive := func() bool { - info := s.chainSvc.GetBuilderByPubkey(s.builderPubkey) - if info == nil { - return false - } - finalizedEpoch := s.chainSvc.GetFinalizedEpoch() - return info.DepositEpoch < uint64(finalizedEpoch) && info.WithdrawableEpoch == chain.FarFutureEpoch - } - // hasProposerPreferences checks whether we have cached preferences for a slot. // Without them the BN's gossip validator will silently reject the bid. hasProposerPreferences := func(slot phase0.Slot) bool { @@ -272,7 +264,8 @@ func (s *Service) Start(ctx context.Context, builderSvc *builder.Service) error s.payloadStore, builderSvc.GetPayloadCache(), s, - isBuilderActive, + s.chainSvc, + s.blsSigner, hasProposerPreferences, s.log, ) diff --git a/pkg/webui/handlers/api/api.go b/pkg/webui/handlers/api/api.go index 26f2744b..5453d9b7 100644 --- a/pkg/webui/handlers/api/api.go +++ b/pkg/webui/handlers/api/api.go @@ -874,6 +874,44 @@ func (h *APIHandler) GetProposerPreferences(w http.ResponseWriter, _ *http.Reque writeJSON(w, http.StatusOK, ProposerPreferencesResponse{Preferences: result}) } +// BuilderPreferencesEntry represents a single cached builder preference for the API response. +type BuilderPreferencesEntry struct { + ValidatorPubkey string `json:"validator_pubkey"` + MaxExecutionPayment uint64 `json:"max_execution_payment"` +} + +// BuilderPreferencesResponse is the response for GetBuilderPreferences. +type BuilderPreferencesResponse struct { + Preferences []BuilderPreferencesEntry `json:"preferences"` +} + +// GetBuilderPreferences godoc +// @Id getBuilderPreferences +// @Summary Get cached builder preferences +// @Tags Buildoor +// @Description Returns all builder preferences currently in the cache, submitted by proposers via the submitBuilderPreferences API. +// @Produce json +// @Success 200 {object} BuilderPreferencesResponse "Success" +// @Failure 404 {object} map[string]string "Builder API not enabled" +// @Router /api/buildoor/builder-preferences [get] +func (h *APIHandler) GetBuilderPreferences(w http.ResponseWriter, _ *http.Request) { + if h.builderAPISvc == nil || h.builderAPISvc.GetBuilderPreferencesStore() == nil { + writeError(w, http.StatusNotFound, "builder API not enabled") + return + } + + entries := h.builderAPISvc.GetBuilderPreferencesStore().GetAll() + result := make([]BuilderPreferencesEntry, 0, len(entries)) + for pubkey, maxPayment := range entries { + result = append(result, BuilderPreferencesEntry{ + ValidatorPubkey: fmt.Sprintf("0x%x", pubkey[:]), + MaxExecutionPayment: uint64(maxPayment), + }) + } + + writeJSON(w, http.StatusOK, BuilderPreferencesResponse{Preferences: result}) +} + // configToMap returns the config as a map with sensitive fields redacted. func configToMap(cfg *builder.Config) map[string]any { if cfg == nil { diff --git a/pkg/webui/handlers/api/builder_preferences_test.go b/pkg/webui/handlers/api/builder_preferences_test.go new file mode 100644 index 00000000..28512ded --- /dev/null +++ b/pkg/webui/handlers/api/builder_preferences_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/builderapi" + "github.com/ethpandaops/buildoor/pkg/config" +) + +func TestGetBuilderPreferences_NotEnabled(t *testing.T) { + // No builder API service wired → 404. + h := NewAPIHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/buildoor/builder-preferences", nil) + rec := httptest.NewRecorder() + h.GetBuilderPreferences(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestGetBuilderPreferences_ReturnsEntries(t *testing.T) { + cfg := &config.BuilderAPIConfig{} + srv := builderapi.NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + + var pk phase0.BLSPubKey + pk[0] = 0xab + srv.GetBuilderPreferencesStore().Set(pk, 5_000_000_000) + + // builderSvc (4th arg) nil so the event stream manager does not start; + // srv is passed as builderAPISvc (9th arg). + h := NewAPIHandler(nil, nil, nil, nil, nil, nil, nil, nil, srv, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/buildoor/builder-preferences", nil) + rec := httptest.NewRecorder() + h.GetBuilderPreferences(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp BuilderPreferencesResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + require.Len(t, resp.Preferences, 1) + assert.Equal(t, "0x"+hex.EncodeToString(pk[:]), resp.Preferences[0].ValidatorPubkey) + assert.Equal(t, uint64(5_000_000_000), resp.Preferences[0].MaxExecutionPayment) +} diff --git a/pkg/webui/src/App.tsx b/pkg/webui/src/App.tsx index b3cd03ee..ebf6e38e 100644 --- a/pkg/webui/src/App.tsx +++ b/pkg/webui/src/App.tsx @@ -6,6 +6,7 @@ const DashboardPage = React.lazy(() => import('./pages/DashboardPage')); const ValidatorsPage = React.lazy(() => import('./pages/ValidatorsPage')); const BidsWonPage = React.lazy(() => import('./pages/BidsWonPage')); const ProposerPreferencesPage = React.lazy(() => import('./pages/ProposerPreferencesPage')); +const BuilderPreferencesPage = React.lazy(() => import('./pages/BuilderPreferencesPage')); const AuditLogPage = React.lazy(() => import('./pages/AuditLogPage')); const ApiDocsPage = React.lazy(() => import('./pages/ApiDocsPage')); @@ -21,6 +22,7 @@ export const App: React.FC = () => { {currentView === 'validators' && } {currentView === 'bids-won' && } {currentView === 'proposer-preferences' && } + {currentView === 'builder-preferences' && } {currentView === 'audit-log' && } {currentView === 'api-docs' && } diff --git a/pkg/webui/src/components/BuilderPreferencesList.tsx b/pkg/webui/src/components/BuilderPreferencesList.tsx new file mode 100644 index 00000000..84542d74 --- /dev/null +++ b/pkg/webui/src/components/BuilderPreferencesList.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import type { BuilderPreference } from '../types'; +import { Pagination } from './Pagination'; + +interface BuilderPreferencesListProps { + preferences: BuilderPreference[]; + loading?: boolean; + error?: string | null; +} + +function copyToClipboard(text: string) { + navigator.clipboard.writeText(text).catch((err) => { + console.error('Failed to copy:', err); + }); +} + +function formatEth(gwei: number): string { + // 1 ETH = 1e9 Gwei + return (gwei / 1e9).toLocaleString(undefined, { maximumFractionDigits: 9 }); +} + +export const BuilderPreferencesList: React.FC = ({ + preferences, + loading, + error, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [offset, setOffset] = useState(0); + const limit = 50; + + const filtered = useMemo(() => { + if (!searchTerm) return preferences; + const term = searchTerm.toLowerCase(); + return preferences.filter( + (p) => + p.validator_pubkey.toLowerCase().includes(term) || + String(p.max_execution_payment).includes(term), + ); + }, [preferences, searchTerm]); + + const total = filtered.length; + const paged = useMemo(() => filtered.slice(offset, offset + limit), [filtered, offset, limit]); + + useEffect(() => { + setOffset(0); + }, [searchTerm]); + + useEffect(() => { + if (total === 0) { + if (offset !== 0) setOffset(0); + return; + } + const maxOffset = Math.floor((total - 1) / limit) * limit; + if (offset > maxOffset) setOffset(maxOffset); + }, [total, offset, limit]); + + if (loading) { + return ( +
+
+
Builder Preferences
+
+
+
Loading...
+
+
+ ); + } + + if (error && error.includes('not enabled')) { + return ( +
+
+
Builder Preferences
+
+
+
+ Builder API not enabled. Run with --builder-api-enabled to receive proposer submissions. +
+
+
+ ); + } + + return ( +
+
+
Builder Preferences
+ {preferences.length} +
+
+ {preferences.length === 0 ? ( +
No builder preferences received yet
+ ) : ( + <> +
+ setSearchTerm(e.target.value)} + /> +
+ +
+ + + + + + + + + + {paged.length === 0 ? ( + + + + ) : ( + paged.map((pref, idx) => ( + + + + + + )) + )} + +
Validator PubkeyMax Execution Payment (Gwei)ETH
+ No preferences match your search +
+ copyToClipboard(pref.validator_pubkey)} + title="Click to copy" + > + {pref.validator_pubkey} + + + {pref.max_execution_payment.toLocaleString()} + + {formatEth(pref.max_execution_payment)} +
+
+ + + + )} +
+
+ ); +}; diff --git a/pkg/webui/src/components/HeaderNav.tsx b/pkg/webui/src/components/HeaderNav.tsx index feeb344f..22d59cb4 100644 --- a/pkg/webui/src/components/HeaderNav.tsx +++ b/pkg/webui/src/components/HeaderNav.tsx @@ -10,6 +10,7 @@ const NAV_ITEMS: Array<{ view: ViewType; label: string; requiresAuth?: boolean } { view: 'bids-won', label: 'Bids Won' }, { view: 'validators', label: 'Validators' }, { view: 'proposer-preferences', label: 'Proposer Prefs' }, + { view: 'builder-preferences', label: 'Builder Prefs' }, { view: 'audit-log', label: 'Audit Log', requiresAuth: true }, { view: 'api-docs', label: 'API' }, ]; diff --git a/pkg/webui/src/hooks/useBuilderPreferences.ts b/pkg/webui/src/hooks/useBuilderPreferences.ts new file mode 100644 index 00000000..fdcb9a28 --- /dev/null +++ b/pkg/webui/src/hooks/useBuilderPreferences.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; +import type { BuilderPreference, BuilderPreferencesResponse } from '../types'; + +export function useBuilderPreferences() { + const [preferences, setPreferences] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const initialFetchDone = useRef(false); + + const fetchPreferences = async () => { + try { + const response = await fetch('/api/buildoor/builder-preferences'); + + if (response.status === 404) { + setPreferences([]); + setError('builder API not enabled'); + return; + } + + if (!response.ok) { + throw new Error(`Failed to fetch builder preferences: ${response.statusText}`); + } + + const data: BuilderPreferencesResponse = await response.json(); + const sorted = (data.preferences || []) + .slice() + .sort((a, b) => b.max_execution_payment - a.max_execution_payment); + setPreferences(sorted); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (!initialFetchDone.current) { + initialFetchDone.current = true; + setLoading(false); + } + } + }; + + useEffect(() => { + fetchPreferences(); + // Poll every 12 seconds (one slot) — proposers submit preferences ahead of their slot + const interval = setInterval(fetchPreferences, 12000); + return () => clearInterval(interval); + }, []); + + return { preferences, loading, error, refetch: fetchPreferences }; +} diff --git a/pkg/webui/src/pages/BuilderPreferencesPage.tsx b/pkg/webui/src/pages/BuilderPreferencesPage.tsx new file mode 100644 index 00000000..0b0264f5 --- /dev/null +++ b/pkg/webui/src/pages/BuilderPreferencesPage.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useBuilderPreferences } from '../hooks/useBuilderPreferences'; +import { BuilderPreferencesList } from '../components/BuilderPreferencesList'; + +const BuilderPreferencesPage: React.FC = () => { + const { preferences, loading, error } = useBuilderPreferences(); + + return ( + + ); +}; + +export default BuilderPreferencesPage; diff --git a/pkg/webui/src/stores/viewStore.ts b/pkg/webui/src/stores/viewStore.ts index abb72349..7ed05ada 100644 --- a/pkg/webui/src/stores/viewStore.ts +++ b/pkg/webui/src/stores/viewStore.ts @@ -1,12 +1,20 @@ import { useSyncExternalStore } from 'react'; -export type ViewType = 'dashboard' | 'bids-won' | 'validators' | 'proposer-preferences' | 'audit-log' | 'api-docs'; +export type ViewType = + | 'dashboard' + | 'bids-won' + | 'validators' + | 'proposer-preferences' + | 'builder-preferences' + | 'audit-log' + | 'api-docs'; const VIEW_PATHS: Record = { dashboard: '/', 'bids-won': '/bids-won', validators: '/validators', 'proposer-preferences': '/proposer-preferences', + 'builder-preferences': '/builder-preferences', 'audit-log': '/audit-log', 'api-docs': '/api-docs', }; diff --git a/pkg/webui/src/types.ts b/pkg/webui/src/types.ts index 8d1a919c..b36d3a24 100644 --- a/pkg/webui/src/types.ts +++ b/pkg/webui/src/types.ts @@ -308,3 +308,13 @@ export interface ProposerPreference { export interface ProposerPreferencesResponse { preferences: ProposerPreference[]; } + +// Builder preferences types +export interface BuilderPreference { + validator_pubkey: string; + max_execution_payment: number; // Gwei +} + +export interface BuilderPreferencesResponse { + preferences: BuilderPreference[]; +} diff --git a/pkg/webui/webui.go b/pkg/webui/webui.go index 151e2766..a7180ef9 100644 --- a/pkg/webui/webui.go +++ b/pkg/webui/webui.go @@ -85,6 +85,7 @@ func StartHttpServer(config *types.FrontendConfig, settingsSvc *settings.Service apiRouter.HandleFunc("/buildoor/builder-api-status", apiHandler.GetBuilderAPIStatus).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/overview", apiHandler.GetOverview).Methods(http.MethodGet, http.MethodOptions) apiRouter.HandleFunc("/buildoor/proposer-preferences", apiHandler.GetProposerPreferences).Methods(http.MethodGet) + apiRouter.HandleFunc("/buildoor/builder-preferences", apiHandler.GetBuilderPreferences).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/audit-log", apiHandler.GetAuditLog).Methods(http.MethodGet) // Lifecycle endpoints (if manager available)