diff --git a/app/app.go b/app/app.go index be14d7323..00f0ec240 100644 --- a/app/app.go +++ b/app/app.go @@ -632,14 +632,15 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, return err } - if len(conf.BuilderURLs) > 0 { - // TODO(gloas): drop this warning once the builder preferences duty authorizes builder entries (#4724). - log.Warn(ctx, "Configured builder URLs are inert for direct gloas builder bids until the builder preferences duty lands (#4724); only P2P and local bids are used", nil, - z.Int("builder_urls", len(conf.BuilderURLs))) + builderCfg := builderConfig(conf) + + hosts, err := builderHosts(conf) + if err != nil { + return err } fetch, err := fetcher.New(ctx, eth2Cl, builderRegSvc.FeeRecipient, conf.BuilderAPI, graffitiBuilder, - forkSchedule, slotsPerEpoch, builderConfig(conf), featureset.Enabled(featureset.FetchOnlyCommIdx0)) + forkSchedule, slotsPerEpoch, builderCfg, featureset.Enabled(featureset.FetchOnlyCommIdx0)) if err != nil { return err } @@ -654,7 +655,8 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, dutyDB := dutydb.NewMemDB(retainerFunc("dutydb")) - vapi, err := validatorapi.NewComponent(eth2Cl, allPubSharesByKey, nodeIdx.ShareIdx, builderRegSvc.FeeRecipient, conf.BuilderAPI, lock.TargetGasLimit) + vapi, err := validatorapi.NewComponent(eth2Cl, allPubSharesByKey, nodeIdx.ShareIdx, builderRegSvc.FeeRecipient, conf.BuilderAPI, lock.TargetGasLimit, + validatorapi.WithBuilderHosts(hosts)) if err != nil { return err } @@ -726,7 +728,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, submissionEth2Cl.SetDutiesCache(dutiesCache.ProposerDutiesCache, dutiesCache.ProposerDutiesV2Cache, dutiesCache.AttesterDutiesCache, dutiesCache.SyncCommDutiesCache) } - broadcaster, err := bcast.New(ctx, submissionEth2Cl, forkSchedule) + broadcaster, err := bcast.New(ctx, submissionEth2Cl, forkSchedule, bcast.WithBuilderConfig(builderCfg)) if err != nil { return err } @@ -762,7 +764,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config, fetch.RegisterSyncContributionV2(isync.SyncContributionsSupported) } - track, err := newTracker(ctx, life, deadlineFunc, peers, eth2Cl) + track, err := newTracker(ctx, life, deadlineFunc, peers, eth2Cl, hosts) if err != nil { return err } @@ -886,7 +888,7 @@ func wirePrioritise(ctx context.Context, conf Config, life *lifecycle.Manager, p // newTracker creates and starts a new tracker instance. func newTracker(ctx context.Context, life *lifecycle.Manager, deadlineFunc func(duty core.Duty) (time.Time, bool), - peers []p2p.Peer, eth2Cl eth2wrap.Client, + peers []p2p.Peer, eth2Cl eth2wrap.Client, builderHosts []string, ) (core.Tracker, error) { slotDuration, _, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) if err != nil { @@ -910,7 +912,7 @@ func newTracker(ctx context.Context, life *lifecycle.Manager, deadlineFunc func( return nil, err } - track := tracker.New(analyser, deleter, peers, trackFrom) + track := tracker.New(analyser, deleter, peers, trackFrom, tracker.WithBuilderHosts(builderHosts)) life.RegisterStart(lifecycle.AsyncBackground, lifecycle.StartTracker, lifecycle.HookFunc(track.Run)) return track, nil diff --git a/app/builderconfig.go b/app/builderconfig.go index 7fea66805..763572272 100644 --- a/app/builderconfig.go +++ b/app/builderconfig.go @@ -9,6 +9,8 @@ import ( "github.com/attestantio/go-eth2-client/spec/gloas" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/obolnetwork/charon/eth2util" ) // builderConfigured returns true if builder URLs are configured, enabling the v2 @@ -22,19 +24,43 @@ func builderConfigured(conf Config) bool { // every v4 EPBS proposal request. MinBid and BuilderBoostFactor gate P2P builder bids // against the locally built payload. // -// No direct builder entries are emitted, even when builder URLs are configured: each -// BuilderEntry requires a per-proposal-slot, proposer-signed authorization, and the eth2 -// client rejects an entry whose auth is missing, which would fail every proposal. Direct -// builder bids therefore stay inert until the builder preferences duty supplies those -// authorizations; the beacon node meanwhile considers P2P and local bids. The builder -// URLs are still distributed to validator clients via the proposer config file and -// surfaced in metrics and peer info. -// TODO(gloas): populate Builders with authorized entries once the builder preferences duty lands (#4724). +// Each configured builder is a direct builder entry without its auth: an entry requires +// the proposer's builder request auth for the proposal slot, which the fetcher adds per +// proposal from the aggregated builder preferences duty, leaving out a builder whose auth +// isn't aggregated. func builderConfig(conf Config) *gloas.BuilderConfig { + builders := make([]*gloas.BuilderEntry, 0, len(conf.BuilderURLs)) + for _, url := range conf.BuilderURLs { + builders = append(builders, &gloas.BuilderEntry{ + URL: []byte(url), + BuilderPubkeys: []eth2p0.BLSPubKey{}, + MaxExecutionPayment: eth2p0.Gwei(conf.BuilderMaxExecutionPayment), + MinBid: eth2p0.Gwei(conf.BuilderMinBid), + BuilderBoostFactor: conf.BuilderBoostFactor, + }) + } + return &gloas.BuilderConfig{ MinBid: eth2p0.Gwei(conf.BuilderMinBid), BuilderBoostFactor: conf.BuilderBoostFactor, + Builders: builders, + } +} + +// builderHosts returns the hosts of the configured builders, the default data of the builder +// request auths validator clients sign for them, in the configured order. +func builderHosts(conf Config) ([]string, error) { + hosts := make([]string, 0, len(conf.BuilderURLs)) + for _, url := range conf.BuilderURLs { + host, err := eth2util.BuilderAuthData(url) + if err != nil { + return nil, err + } + + hosts = append(hosts, string(host)) } + + return hosts, nil } // builderConfigHash returns a digest of the canonicalised builder configuration. diff --git a/app/builderconfig_internal_test.go b/app/builderconfig_internal_test.go index 5310d2372..01d90f2df 100644 --- a/app/builderconfig_internal_test.go +++ b/app/builderconfig_internal_test.go @@ -9,9 +9,8 @@ import ( ) func TestBuilderConfig(t *testing.T) { - // No direct builder entries are emitted even with builder URLs configured: the eth2 - // client rejects an entry with a nil auth, which would fail every proposal. MinBid and - // BuilderBoostFactor still apply, gating P2P bids. + // Each configured builder is an entry without its auth: the fetcher adds the aggregated + // builder request auth per proposal. MinBid and BuilderBoostFactor also gate P2P bids. conf := Config{ BuilderURLs: []string{"https://a.example.com", "https://b.example.com"}, BuilderMinBid: 7, @@ -20,9 +19,20 @@ func TestBuilderConfig(t *testing.T) { } cfg := builderConfig(conf) - require.Empty(t, cfg.Builders) require.EqualValues(t, 7, cfg.MinBid) require.EqualValues(t, 120, cfg.BuilderBoostFactor) + require.Len(t, cfg.Builders, 2) + + for i, entry := range cfg.Builders { + require.Equal(t, []byte(conf.BuilderURLs[i]), entry.URL) + require.Nil(t, entry.Auth) + require.Empty(t, entry.BuilderPubkeys) + require.EqualValues(t, 3, entry.MaxExecutionPayment) + require.EqualValues(t, 7, entry.MinBid) + require.EqualValues(t, 120, entry.BuilderBoostFactor) + } + + require.Empty(t, builderConfig(Config{BuilderMinBid: 7}).Builders) } func TestBuilderConfigured(t *testing.T) { @@ -65,3 +75,13 @@ func TestBuilderConfigHash(t *testing.T) { // An unconfigured node still produces a stable non-empty hash. require.Len(t, builderConfigHash(Config{}), 32) } + +func TestBuilderHosts(t *testing.T) { + hosts, err := builderHosts(Config{BuilderURLs: []string{"https://Relay-A.example.com:443/path", "http://relay-b.example.com"}}) + require.NoError(t, err) + require.Equal(t, []string{"relay-a.example.com", "relay-b.example.com"}, hosts) + + hosts, err = builderHosts(Config{}) + require.NoError(t, err) + require.Empty(t, hosts) +} diff --git a/app/eth2wrap/eth2wrap_gen.go b/app/eth2wrap/eth2wrap_gen.go index c62ee9e54..0050d4211 100644 --- a/app/eth2wrap/eth2wrap_gen.go +++ b/app/eth2wrap/eth2wrap_gen.go @@ -15,7 +15,6 @@ import ( "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/electra" - "github.com/attestantio/go-eth2-client/spec/gloas" "github.com/attestantio/go-eth2-client/spec/phase0" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -51,6 +50,7 @@ type Client interface { eth2client.BeaconCommitteeSubscriptionsSubmitter eth2client.BeaconCommitteesProvider eth2client.BlindedProposalSubmitter + eth2client.BuilderPreferencesSubmitter eth2client.DepositContractProvider eth2client.DomainProvider eth2client.EPBSProposalProvider @@ -64,10 +64,10 @@ type Client interface { eth2client.NodeSyncingProvider eth2client.NodeVersionProvider eth2client.NodeVersionV2Provider - eth2client.PendingDepositProvider eth2client.PTCDutiesProvider eth2client.PayloadAttestationDataProvider eth2client.PayloadAttestationMessagesSubmitter + eth2client.PendingDepositProvider eth2client.ProposalPreparationsSubmitter eth2client.ProposalProvider eth2client.ProposalSubmitter @@ -1099,14 +1099,34 @@ func (m multi) SubmitPayloadAttestationMessages(ctx context.Context, opts *api.S return err } -func (m multi) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { +func (m multi) SubmitBuilderPreferences(ctx context.Context, opts *api.SubmitBuilderPreferencesOpts) error { + const label = "submit_builder_preferences" + defer latency(ctx, label, false)() + defer incRequest(label) + + err := submit(ctx, m.clients, m.fallbacks, + func(ctx context.Context, args provideArgs) error { + return args.client.SubmitBuilderPreferences(ctx, opts) + }, + m.selector, + ) + + if err != nil { + incError(label) + err = wrapError(ctx, err, label) + } + + return err +} + +func (m multi) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { const label = "submit_proposer_preferences" defer latency(ctx, label, false)() defer incRequest(label) err := submit(ctx, m.clients, m.fallbacks, func(ctx context.Context, args provideArgs) error { - return args.client.SubmitProposerPreferences(ctx, preferences) + return args.client.SubmitProposerPreferences(ctx, opts) }, m.selector, ) @@ -1599,13 +1619,22 @@ func (l *lazy) SubmitPayloadAttestationMessages(ctx context.Context, opts *api.S return cl.SubmitPayloadAttestationMessages(ctx, opts) } -func (l *lazy) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) (err error) { +func (l *lazy) SubmitBuilderPreferences(ctx context.Context, opts *api.SubmitBuilderPreferencesOpts) (err error) { + cl, err := l.getOrCreateClient(ctx) + if err != nil { + return err + } + + return cl.SubmitBuilderPreferences(ctx, opts) +} + +func (l *lazy) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) (err error) { cl, err := l.getOrCreateClient(ctx) if err != nil { return err } - return cl.SubmitProposerPreferences(ctx, preferences) + return cl.SubmitProposerPreferences(ctx, opts) } // Proxy performs an HTTP proxy request and returns the response. diff --git a/app/eth2wrap/genwrap/genwrap.go b/app/eth2wrap/genwrap/genwrap.go index f0b95f403..da7dd00fd 100644 --- a/app/eth2wrap/genwrap/genwrap.go +++ b/app/eth2wrap/genwrap/genwrap.go @@ -128,6 +128,7 @@ type Client interface { "BeaconCommitteesProvider": {Latency: true, Log: false}, "BlindedProposalProvider": {Latency: true, Log: false}, "BlindedProposalSubmitter": {Latency: true, Log: false}, + "BuilderPreferencesSubmitter": {Latency: true, Log: false}, "DepositContractProvider": {Latency: false, Log: false}, "DomainProvider": {Latency: false, Log: false}, "ForkProvider": {Latency: true, Log: false}, diff --git a/app/eth2wrap/mocks/client.go b/app/eth2wrap/mocks/client.go index cb4e9665d..c5fa95b89 100644 --- a/app/eth2wrap/mocks/client.go +++ b/app/eth2wrap/mocks/client.go @@ -10,9 +10,9 @@ import ( context "context" - eth2wrap "github.com/obolnetwork/charon/app/eth2wrap" + electra "github.com/attestantio/go-eth2-client/spec/electra" - gloas "github.com/attestantio/go-eth2-client/spec/gloas" + eth2wrap "github.com/obolnetwork/charon/app/eth2wrap" http "net/http" @@ -22,8 +22,6 @@ import ( spec "github.com/attestantio/go-eth2-client/spec" - electra "github.com/attestantio/go-eth2-client/spec/electra" - time "time" v1 "github.com/attestantio/go-eth2-client/api/v1" @@ -804,28 +802,28 @@ func (_m *Client) NodeVersionV2(ctx context.Context, opts *api.NodeVersionV2Opts return r0, r1 } -// PendingDeposits provides a mock function with given fields: ctx, opts -func (_m *Client) PendingDeposits(ctx context.Context, opts *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) { +// PTCDuties provides a mock function with given fields: ctx, opts +func (_m *Client) PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api.Response[[]*v1.PTCDuty], error) { ret := _m.Called(ctx, opts) if len(ret) == 0 { - panic("no return value specified for PendingDeposits") + panic("no return value specified for PTCDuties") } - var r0 *api.Response[[]*electra.PendingDeposit] + var r0 *api.Response[[]*v1.PTCDuty] var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *api.PTCDutiesOpts) (*api.Response[[]*v1.PTCDuty], error)); ok { return rf(ctx, opts) } - if rf, ok := ret.Get(0).(func(context.Context, *api.PendingDepositsOpts) *api.Response[[]*electra.PendingDeposit]); ok { + if rf, ok := ret.Get(0).(func(context.Context, *api.PTCDutiesOpts) *api.Response[[]*v1.PTCDuty]); ok { r0 = rf(ctx, opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*api.Response[[]*electra.PendingDeposit]) + r0 = ret.Get(0).(*api.Response[[]*v1.PTCDuty]) } } - if rf, ok := ret.Get(1).(func(context.Context, *api.PendingDepositsOpts) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *api.PTCDutiesOpts) error); ok { r1 = rf(ctx, opts) } else { r1 = ret.Error(1) @@ -834,28 +832,28 @@ func (_m *Client) PendingDeposits(ctx context.Context, opts *api.PendingDeposits return r0, r1 } -// PTCDuties provides a mock function with given fields: ctx, opts -func (_m *Client) PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api.Response[[]*v1.PTCDuty], error) { +// PayloadAttestationData provides a mock function with given fields: ctx, opts +func (_m *Client) PayloadAttestationData(ctx context.Context, opts *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) { ret := _m.Called(ctx, opts) if len(ret) == 0 { - panic("no return value specified for PTCDuties") + panic("no return value specified for PayloadAttestationData") } - var r0 *api.Response[[]*v1.PTCDuty] + var r0 *api.Response[*spec.VersionedPayloadAttestationData] var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *api.PTCDutiesOpts) (*api.Response[[]*v1.PTCDuty], error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error)); ok { return rf(ctx, opts) } - if rf, ok := ret.Get(0).(func(context.Context, *api.PTCDutiesOpts) *api.Response[[]*v1.PTCDuty]); ok { + if rf, ok := ret.Get(0).(func(context.Context, *api.PayloadAttestationDataOpts) *api.Response[*spec.VersionedPayloadAttestationData]); ok { r0 = rf(ctx, opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*api.Response[[]*v1.PTCDuty]) + r0 = ret.Get(0).(*api.Response[*spec.VersionedPayloadAttestationData]) } } - if rf, ok := ret.Get(1).(func(context.Context, *api.PTCDutiesOpts) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *api.PayloadAttestationDataOpts) error); ok { r1 = rf(ctx, opts) } else { r1 = ret.Error(1) @@ -864,28 +862,28 @@ func (_m *Client) PTCDuties(ctx context.Context, opts *api.PTCDutiesOpts) (*api. return r0, r1 } -// PayloadAttestationData provides a mock function with given fields: ctx, opts -func (_m *Client) PayloadAttestationData(ctx context.Context, opts *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error) { +// PendingDeposits provides a mock function with given fields: ctx, opts +func (_m *Client) PendingDeposits(ctx context.Context, opts *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) { ret := _m.Called(ctx, opts) if len(ret) == 0 { - panic("no return value specified for PayloadAttestationData") + panic("no return value specified for PendingDeposits") } - var r0 *api.Response[*spec.VersionedPayloadAttestationData] + var r0 *api.Response[[]*electra.PendingDeposit] var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *api.PayloadAttestationDataOpts) (*api.Response[*spec.VersionedPayloadAttestationData], error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error)); ok { return rf(ctx, opts) } - if rf, ok := ret.Get(0).(func(context.Context, *api.PayloadAttestationDataOpts) *api.Response[*spec.VersionedPayloadAttestationData]); ok { + if rf, ok := ret.Get(0).(func(context.Context, *api.PendingDepositsOpts) *api.Response[[]*electra.PendingDeposit]); ok { r0 = rf(ctx, opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*api.Response[*spec.VersionedPayloadAttestationData]) + r0 = ret.Get(0).(*api.Response[[]*electra.PendingDeposit]) } } - if rf, ok := ret.Get(1).(func(context.Context, *api.PayloadAttestationDataOpts) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, *api.PendingDepositsOpts) error); ok { r1 = rf(ctx, opts) } else { r1 = ret.Error(1) @@ -1303,6 +1301,24 @@ func (_m *Client) SubmitBlindedProposal(ctx context.Context, opts *api.SubmitBli return r0 } +// SubmitBuilderPreferences provides a mock function with given fields: ctx, opts +func (_m *Client) SubmitBuilderPreferences(ctx context.Context, opts *api.SubmitBuilderPreferencesOpts) error { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for SubmitBuilderPreferences") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitBuilderPreferencesOpts) error); ok { + r0 = rf(ctx, opts) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SubmitExecutionPayloadEnvelope provides a mock function with given fields: ctx, opts func (_m *Client) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { ret := _m.Called(ctx, opts) @@ -1375,17 +1391,17 @@ func (_m *Client) SubmitProposalPreparations(ctx context.Context, preparations [ return r0 } -// SubmitProposerPreferences provides a mock function with given fields: ctx, preferences -func (_m *Client) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { - ret := _m.Called(ctx, preferences) +// SubmitProposerPreferences provides a mock function with given fields: ctx, opts +func (_m *Client) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { + ret := _m.Called(ctx, opts) if len(ret) == 0 { panic("no return value specified for SubmitProposerPreferences") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, []*gloas.SignedProposerPreferences) error); ok { - r0 = rf(ctx, preferences) + if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitProposerPreferencesOpts) error); ok { + r0 = rf(ctx, opts) } else { r0 = ret.Error(0) } diff --git a/app/vmock.go b/app/vmock.go index d12e198b2..4d3eb9bda 100644 --- a/app/vmock.go +++ b/app/vmock.go @@ -47,7 +47,7 @@ func wireValidatorMock(ctx context.Context, conf Config, eth2Cl eth2wrap.Client, return err } - vmock := validatormock.New(ctx, newVMockEth2Provider(conf, pubshares), signer, pubshares, genesisTime, slotDuration, slotsPerEpoch, slotOffsetFunc, conf.BuilderAPI) + vmock := validatormock.New(ctx, newVMockEth2Provider(conf, pubshares), signer, pubshares, genesisTime, slotDuration, slotsPerEpoch, slotOffsetFunc, conf.BuilderAPI, conf.BuilderURLs) sched.SubscribeSlots(vmock.SlotTicked) return nil diff --git a/core/aggsigdb/memory.go b/core/aggsigdb/memory.go index 31e7e82a4..6130aa4d5 100644 --- a/core/aggsigdb/memory.go +++ b/core/aggsigdb/memory.go @@ -10,6 +10,7 @@ import ( eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/core" ) @@ -24,15 +25,16 @@ var ( // NewMemDB creates a basic memory based AggSigDB. func NewMemDB(deadliner core.Deadliner) *MemDB { return &MemDB{ - generalDuties: newAggStore[generalKey](), - subCommDuties: newAggStore[subCommKey](), - propPrefDuties: newAggStore[propPrefKey](), - commands: make(chan writeCommand), - queries: make(chan readQuery), - blockedQueries: []readQuery{}, - queryCallback: func([]readQuery) {}, - quit: make(chan struct{}), - deadliner: deadliner, + generalDuties: newAggStore[generalKey](), + subCommDuties: newAggStore[subCommKey](), + propPrefDuties: newAggStore[propPrefKey](), + builderPrefDuties: newAggStore[builderPrefKey](), + commands: make(chan writeCommand), + queries: make(chan readQuery), + blockedQueries: []readQuery{}, + queryCallback: func([]readQuery) {}, + quit: make(chan struct{}), + deadliner: deadliner, } } @@ -49,6 +51,9 @@ type MemDB struct { // propPrefDuties holds proposer preferences, additionally keyed by the fields // that may legitimately change on resubmission. They are not awaitable, only broadcasted. propPrefDuties *aggStore[propPrefKey] + // builderPrefDuties holds builder request auths, additionally keyed by the builder's hostname + // since a proposer authorizes several builders for the same proposal slot. + builderPrefDuties *aggStore[builderPrefKey] commands chan writeCommand queries chan readQuery @@ -103,23 +108,25 @@ func (db *MemDB) store(ctx context.Context, duty core.Duty, pubKey core.PubKey, } // Await implements core.AggSigDB, see its godoc. -func (db *MemDB) Await(ctx context.Context, duty core.Duty, pubKey core.PubKey, subcommIdx core.SubcommitteeIndex) (core.SignedData, error) { - if duty.Type == core.DutyProposerPreferences { +func (db *MemDB) Await(ctx context.Context, duty core.Duty, pubKey core.PubKey, awaitOpts core.AwaitOpts) (core.SignedData, error) { + switch duty.Type { + case core.DutyProposerPreferences: return nil, errNotAwaitable + case core.DutyBuilderPreferences: + if awaitOpts.BuilderHost == "" { + return nil, errors.New("builder host required to await builder request auth", z.Any("duty", duty)) + } + default: } + query := readQuery{duty: duty, pubKey: pubKey, opts: awaitOpts} + cancel := make(chan struct{}) defer close(cancel) response := make(chan core.SignedData, 1) - - query := readQuery{ - duty: duty, - pubKey: pubKey, - subcommIdx: subcommIdx, - response: response, - cancel: cancel, - } + query.response = response + query.cancel = cancel select { case <-ctx.Done(): @@ -158,6 +165,7 @@ func (db *MemDB) Run(ctx context.Context) { db.generalDuties.trim(duty) db.subCommDuties.trim(duty) db.propPrefDuties.trim(duty) + db.builderPrefDuties.trim(duty) case <-ctx.Done(): return } @@ -178,7 +186,7 @@ func (db *MemDB) execCommand(command writeCommand) { // execQuery returns true if the query was successfully executed. // If the requested entry is found in the DB it will be returned via query.response channel. func (db *MemDB) execQuery(query readQuery) bool { - data, ok := db.get(query.duty, query.pubKey, query.subcommIdx) + data, ok := db.get(query) if !ok { return false } @@ -236,9 +244,9 @@ type writeCommand struct { // readQuery holds the query data and the response channel. type readQuery struct { - duty core.Duty - pubKey core.PubKey - subcommIdx core.SubcommitteeIndex + duty core.Duty + pubKey core.PubKey + opts core.AwaitOpts response chan<- core.SignedData cancel <-chan struct{} @@ -271,6 +279,15 @@ type propPrefKey struct { targetGasLimit uint64 } +// builderPrefKey additionally carries the builder's hostname, the identity signed in the request +// auth, e.g. "relay-a.example.com" for the builder URL https://relay-a.example.com: a proposer +// authorizes each builder it requests bids from separately for the same proposal slot. +type builderPrefKey struct { + duty core.Duty + pubKey core.PubKey + builderHost string +} + // propPrefKeyFor returns the propPrefKey for the provided proposer preferences aggregate. func propPrefKeyFor(duty core.Duty, pubKey core.PubKey, data core.SignedData) (propPrefKey, error) { pref, ok := data.(core.SignedProposerPreferences) @@ -305,19 +322,28 @@ func (db *MemDB) storeRouted(duty core.Duty, pubKey core.PubKey, data core.Signe } return db.propPrefDuties.store(duty, k, data) + case core.DutyBuilderPreferences: + auth, ok := data.(core.SignedBuilderRequestAuth) + if !ok || auth.Message == nil { + return errors.New("invalid builder request auth data") + } + + return db.builderPrefDuties.store(duty, builderPrefKey{duty: duty, pubKey: pubKey, builderHost: string(auth.Message.Data)}, data) default: return db.generalDuties.store(duty, generalKey{duty: duty, pubKey: pubKey}, data) } } -// get returns the aggregate for the provided awaitable duty. Proposer preferences are not -// awaitable, Await rejects them before querying. -func (db *MemDB) get(duty core.Duty, pubKey core.PubKey, subcommIdx core.SubcommitteeIndex) (core.SignedData, bool) { - switch duty.Type { +// get returns the aggregate for the provided query. Proposer preferences are not awaitable, +// Await rejects them before querying. +func (db *MemDB) get(query readQuery) (core.SignedData, bool) { + switch query.duty.Type { case core.DutyPrepareSyncContribution, core.DutySyncContribution: - return db.subCommDuties.get(subCommKey{duty: duty, pubKey: pubKey, subcommIdx: subcommIdx}) + return db.subCommDuties.get(subCommKey{duty: query.duty, pubKey: query.pubKey, subcommIdx: query.opts.SubcommIdx}) + case core.DutyBuilderPreferences: + return db.builderPrefDuties.get(builderPrefKey{duty: query.duty, pubKey: query.pubKey, builderHost: query.opts.BuilderHost}) default: - return db.generalDuties.get(generalKey{duty: duty, pubKey: pubKey}) + return db.generalDuties.get(generalKey{duty: query.duty, pubKey: query.pubKey}) } } diff --git a/core/aggsigdb/memory_internal_test.go b/core/aggsigdb/memory_internal_test.go index e43f8456f..f68c56e86 100644 --- a/core/aggsigdb/memory_internal_test.go +++ b/core/aggsigdb/memory_internal_test.go @@ -6,6 +6,7 @@ import ( "context" "sync" "testing" + "time" "github.com/stretchr/testify/require" @@ -33,7 +34,7 @@ func TestDutyExpiration(t *testing.T) { err := db.Store(ctx, duty, core.SignedDataSet{pubkey: sig}) require.NoError(t, err) - resp, err := db.Await(ctx, duty, pubkey, 0) + resp, err := db.Await(ctx, duty, pubkey, core.AwaitOpts{}) require.NoError(t, err) require.Equal(t, sig, resp) @@ -78,7 +79,7 @@ func TestCancelledQuery(t *testing.T) { errCh := make(chan error, 2) go func() { - _, err := db.Await(qctx, duty, pubkey, 0) + _, err := db.Await(qctx, duty, pubkey, core.AwaitOpts{}) errCh <- err wg.Done() @@ -87,7 +88,7 @@ func TestCancelledQuery(t *testing.T) { require.Equal(t, 1, <-queryCount) wg.Go(func() { - _, err := db.Await(qctx, duty, pubkey, 0) + _, err := db.Await(qctx, duty, pubkey, core.AwaitOpts{}) errCh <- err }) @@ -182,3 +183,98 @@ func TestProposerPreferencesReorg(t *testing.T) { require.Len(t, db.propPrefDuties.data, 2) } + +// TestAwaitBuilderRequestAuth verifies that builder request auths for several builders of the +// same duty and pubkey are stored independently and awaited by the builder's auth data. +func TestAwaitBuilderRequestAuth(t *testing.T) { + var wg sync.WaitGroup + + ctx, cancel := context.WithCancel(context.Background()) + defer func() { + cancel() + wg.Wait() + }() + + db := NewMemDB(newTestDeadliner()) + + wg.Go(func() { + db.Run(ctx) + }) + + pubkey := testutil.RandomCorePubKey(t) + + authA := testutil.RandomSignedBuilderRequestAuth() + authA.Message.Data = []byte("relay-a.example.com") + duty := core.NewBuilderPreferencesDuty(uint64(authA.Message.Slot)) + + authB := testutil.RandomSignedBuilderRequestAuth() + authB.Message.Data = []byte("relay-b.example.com") + authB.Message.Slot = authA.Message.Slot + + // Awaiting the duty without naming a builder is an error. + _, err := db.Await(ctx, duty, pubkey, core.AwaitOpts{}) + require.ErrorContains(t, err, "builder host required") + + // Awaiting builder B blocks until its aggregate is stored, not builder A's. + awaited := make(chan core.SignedData, 1) + + wg.Go(func() { + data, err := db.Await(ctx, duty, pubkey, core.AwaitOpts{BuilderHost: string(authB.Message.Data)}) + if err == nil { + awaited <- data + } + }) + + require.NoError(t, db.Store(ctx, duty, core.SignedDataSet{pubkey: core.NewSignedBuilderRequestAuth(authA)})) + require.NoError(t, db.Store(ctx, duty, core.SignedDataSet{pubkey: core.NewSignedBuilderRequestAuth(authB)})) + + select { + case data := <-awaited: + require.Equal(t, core.NewSignedBuilderRequestAuth(authB), data) + case <-time.After(time.Second): + require.Fail(t, "builder B's auth not returned") + } + + // Builder A's aggregate is returned for its own auth data. + data, err := db.Await(ctx, duty, pubkey, core.AwaitOpts{BuilderHost: string(authA.Message.Data)}) + require.NoError(t, err) + require.Equal(t, core.NewSignedBuilderRequestAuth(authA), data) +} + +// TestAwaitOptsValidation verifies which duties can be awaited and the options they require; +// options that don't apply to a duty are ignored. A valid query of a database that isn't +// running blocks until the context expires. +func TestAwaitOptsValidation(t *testing.T) { + db := NewMemDB(newTestDeadliner()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tests := []struct { + name string + duty core.Duty + opts core.AwaitOpts + err string + }{ + {name: "randao", duty: core.NewRandaoDuty(1)}, + {name: "randao with subcommittee", duty: core.NewRandaoDuty(1), opts: core.AwaitOpts{SubcommIdx: 1}}, + {name: "randao with builder", duty: core.NewRandaoDuty(1), opts: core.AwaitOpts{BuilderHost: "relay-a.example.com"}}, + {name: "sync contribution", duty: core.NewSyncContributionDuty(1), opts: core.AwaitOpts{SubcommIdx: 2}}, + {name: "sync contribution with builder", duty: core.NewSyncContributionDuty(1), opts: core.AwaitOpts{SubcommIdx: 2, BuilderHost: "relay-a.example.com"}}, + {name: "builder preferences", duty: core.NewBuilderPreferencesDuty(1), opts: core.AwaitOpts{BuilderHost: "relay-a.example.com"}}, + {name: "builder preferences without builder", duty: core.NewBuilderPreferencesDuty(1), err: "builder host required"}, + {name: "builder preferences with subcommittee", duty: core.NewBuilderPreferencesDuty(1), opts: core.AwaitOpts{BuilderHost: "relay-a.example.com", SubcommIdx: 1}}, + {name: "proposer preferences", duty: core.NewProposerPreferencesDuty(1), err: errNotAwaitable.Error()}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := db.Await(ctx, test.duty, testutil.RandomCorePubKey(t), test.opts) + if test.err != "" { + require.ErrorContains(t, err, test.err) + } else { + require.ErrorIs(t, err, context.Canceled) + } + }) + } +} diff --git a/core/aggsigdb/memory_test.go b/core/aggsigdb/memory_test.go index bc88e850b..111df10f4 100644 --- a/core/aggsigdb/memory_test.go +++ b/core/aggsigdb/memory_test.go @@ -37,7 +37,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { err := db.Store(context.Background(), testDuty, core.SignedDataSet{testPubKey: testSignedData}) require.NoError(t, err) - result, err := db.Await(context.Background(), testDuty, testPubKey, 0) + result, err := db.Await(context.Background(), testDuty, testPubKey, core.AwaitOpts{}) require.NoError(t, err) require.EqualValues(t, testSignedData, result) @@ -59,7 +59,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { }) go func() { - result, err := db.Await(context.Background(), testDuty, testPubKey, 0) + result, err := db.Await(context.Background(), testDuty, testPubKey, core.AwaitOpts{}) resChan <- struct { result core.SignedData err error @@ -89,7 +89,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { ctx2, cancel2 := context.WithCancel(context.Background()) go func() { - _, err := db.Await(ctx2, testDuty, testPubKey, 0) + _, err := db.Await(ctx2, testDuty, testPubKey, core.AwaitOpts{}) errChan <- err }() @@ -114,7 +114,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { ctx2, cancel2 := context.WithCancel(context.Background()) cancel2() - _, err := db.Await(ctx2, testDuty, testPubKey, 0) + _, err := db.Await(ctx2, testDuty, testPubKey, core.AwaitOpts{}) require.Error(t, err) require.Equal(t, "context canceled", err.Error()) }) @@ -135,7 +135,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { errChan := make(chan error) go func() { - _, err := db.Await(context.Background(), testDuty, testPubKey, 0) + _, err := db.Await(context.Background(), testDuty, testPubKey, core.AwaitOpts{}) errChan <- err }() @@ -187,7 +187,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { err = db.Store(context.Background(), testDuty, core.SignedDataSet{testPubKey: testSignedData}) require.NoError(t, err) - result, err := db.Await(context.Background(), testDuty, testPubKey, 0) + result, err := db.Await(context.Background(), testDuty, testPubKey, core.AwaitOpts{}) require.NoError(t, err) require.EqualValues(t, testSignedData, result) }) @@ -205,7 +205,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { err := db.Store(context.Background(), testDuty, core.SignedDataSet{testPubKey: testSignedData}) require.NoError(t, err) - result, err := db.Await(context.Background(), testDuty, testPubKey, 0) + result, err := db.Await(context.Background(), testDuty, testPubKey, core.AwaitOpts{}) require.NoError(t, err) require.EqualValues(t, testSignedData, result) @@ -216,7 +216,7 @@ func testMemDB(t *testing.T, newMemDB func(core.Deadliner) core.AggSigDB) { err = db.Store(context.Background(), testDuty, core.SignedDataSet{testPubKey: testSignedData}) require.Equal(t, err.Error(), aggsigdb.ErrStopped.Error()) - _, err = db.Await(context.Background(), testDuty, testPubKey, 0) + _, err = db.Await(context.Background(), testDuty, testPubKey, core.AwaitOpts{}) require.Equal(t, err.Error(), aggsigdb.ErrStopped.Error()) }) } diff --git a/core/bcast/bcast.go b/core/bcast/bcast.go index ddf616f5e..ce8ed07de 100644 --- a/core/bcast/bcast.go +++ b/core/bcast/bcast.go @@ -6,6 +6,8 @@ package bcast import ( "context" + "encoding/json" + "net/http" "strings" "time" @@ -20,26 +22,45 @@ import ( "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" "github.com/obolnetwork/charon/eth2util/signing" "github.com/obolnetwork/charon/tbls" ) // New returns a new broadcaster instance. -func New(ctx context.Context, eth2Cl eth2wrap.Client, forkSchedule func() eth2wrap.ForkForkSchedule) (Broadcaster, error) { +func New(ctx context.Context, eth2Cl eth2wrap.Client, forkSchedule func() eth2wrap.ForkForkSchedule, opts ...Option) (Broadcaster, error) { delayFunc, err := newDelayFunc(ctx, eth2Cl, forkSchedule) if err != nil { return Broadcaster{}, err } - return Broadcaster{ + b := Broadcaster{ eth2Cl: eth2Cl, delayFunc: delayFunc, - }, nil + } + + for _, opt := range opts { + opt(&b) + } + + return b, nil +} + +// Option configures the broadcaster. +type Option func(*Broadcaster) + +// WithBuilderConfig sets the builder configuration whose builders the aggregated builder request +// auths are submitted to the beacon node for. +func WithBuilderConfig(config *gloas.BuilderConfig) Option { + return func(b *Broadcaster) { + b.builderConfig = config + } } type Broadcaster struct { - eth2Cl eth2wrap.Client - delayFunc func(slot uint64, duty core.DutyType) time.Duration + eth2Cl eth2wrap.Client + delayFunc func(slot uint64, duty core.DutyType) time.Duration + builderConfig *gloas.BuilderConfig } // Broadcast broadcasts the aggregated signed duty data object to the beacon-node. @@ -285,7 +306,7 @@ func (b Broadcaster) Broadcast(ctx context.Context, duty core.Duty, set core.Sig return err } - err = b.eth2Cl.SubmitProposerPreferences(ctx, prefs) + err = b.eth2Cl.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: prefs}) if err == nil { log.Info(ctx, "Successfully submitted proposer preferences to beacon node", z.Any("delay", b.delayFunc(duty.Slot, core.DutyProposerPreferences)), @@ -294,6 +315,25 @@ func (b Broadcaster) Broadcast(ctx context.Context, duty core.Duty, set core.Sig } return err + case core.DutyBuilderPreferences: + entries, err := b.builderPreferencesEntries(ctx, set) + if err != nil { + return err + } else if len(entries) == 0 { + return nil + } + + err = b.eth2Cl.SubmitBuilderPreferences(ctx, ð2api.SubmitBuilderPreferencesOpts{Preferences: entries}) + if err == nil { + log.Info(ctx, "Successfully submitted builder preferences to beacon node", + z.Any("delay", b.delayFunc(duty.Slot, core.DutyBuilderPreferences)), + z.Int("amount", len(entries)), + ) + + return nil + } + + return handleBuilderPreferencesError(ctx, err, entries) case core.DutyAggregator: aggAndProofs, err := setToAggAndProof(set) if err != nil { @@ -426,6 +466,127 @@ func setToPayloadAttestationMessages(set core.SignedDataSet) (*eth2api.SubmitPay return ð2api.SubmitPayloadAttestationMessagesOpts{Messages: resp}, nil } +// builderPreferencesEntries returns the builder preferences entries of the aggregated builder +// request auths: each auth is submitted for the configured builders it authorizes, identified by +// its auth data, which builders sharing a host share. The builder's URL and maximum execution +// payment come from charon's builder configuration, which is identical across the cluster. +func (b Broadcaster) builderPreferencesEntries(ctx context.Context, set core.SignedDataSet) ([]*gloas.BuilderPreferencesEntry, error) { + var resp []*gloas.BuilderPreferencesEntry + + for pubkey, data := range set { + auth, ok := data.(core.SignedBuilderRequestAuth) + if !ok || auth.Message == nil { + return nil, errors.New("invalid builder request auth") + } + + builders := b.configuredBuilders(auth.Message.Data) + if len(builders) == 0 { + log.Warn(ctx, "Not submitting builder request auth of a builder that isn't configured", nil, + z.Str("auth_data", string(auth.Message.Data))) + + continue + } + + pubkeyBytes, err := pubkey.Bytes() + if err != nil { + return nil, err + } + + for _, builder := range builders { + resp = append(resp, &gloas.BuilderPreferencesEntry{ + ProposerPubkey: eth2p0.BLSPubKey(pubkeyBytes), + URL: builder.URL, + Auth: &auth.SignedBuilderRequestAuth, + MaxExecutionPayment: builder.MaxExecutionPayment, + }) + } + } + + return resp, nil +} + +// configuredBuilders returns the configured builder entries the auth data identifies, in +// configured order: several when builders share a host, e.g. on different ports. +func (b Broadcaster) configuredBuilders(authData []byte) []*gloas.BuilderEntry { + if b.builderConfig == nil { + return nil + } + + var resp []*gloas.BuilderEntry + + for _, entry := range b.builderConfig.Builders { + data, err := eth2util.BuilderAuthData(string(entry.URL)) + if err == nil && string(data) == string(authData) { + resp = append(resp, entry) + } + } + + return resp +} + +// handleBuilderPreferencesError handles a failed builder preferences submission. Like validator +// registrations, a rejected submission isn't retried: the beacon node submitted the other entries +// (400) or doesn't serve the endpoint (404), and the auth reaches the builder with the bid request +// at proposal time regardless. Any other error is returned for the caller to retry. +func handleBuilderPreferencesError(ctx context.Context, err error, entries []*gloas.BuilderPreferencesEntry) error { + var apiErr *eth2api.Error + if !errors.As(err, &apiErr) { + return err + } + + switch apiErr.StatusCode { + case http.StatusBadRequest: + for _, failure := range builderPreferencesFailures(apiErr.Data, entries) { + log.Warn(ctx, "Builder rejected builder preferences", nil, + z.Str("builder", failure.builder), z.Str("message", failure.message)) + builderPreferencesRejected.WithLabelValues(failure.builder).Inc() + } + + return nil + case http.StatusNotFound: + log.Warn(ctx, "Beacon node doesn't support submitting builder preferences, builders only receive the request auth with the bid request", err) + + return nil + default: + return err + } +} + +// builderPreferencesFailure is a builder's rejection of its builder preferences entry. +type builderPreferencesFailure struct { + builder string + message string +} + +// builderPreferencesFailures returns the failed entries of a builder preferences submission from +// the beacon node's indexed error message. If it can't be parsed, the whole submission is reported. +func builderPreferencesFailures(data []byte, entries []*gloas.BuilderPreferencesEntry) []builderPreferencesFailure { + var indexed struct { + Message string `json:"message"` + Failures []struct { + Index int `json:"index"` + Message string `json:"message"` + } `json:"failures"` + } + + if err := json.Unmarshal(data, &indexed); err != nil || len(indexed.Failures) == 0 { + return []builderPreferencesFailure{{builder: "unknown", message: strings.TrimSpace(string(data))}} + } + + var resp []builderPreferencesFailure + + for _, failure := range indexed.Failures { + builder := "unknown" + if failure.Index >= 0 && failure.Index < len(entries) { + builder = string(entries[failure.Index].URL) + } + + resp = append(resp, builderPreferencesFailure{builder: builder, message: failure.Message}) + } + + return resp +} + // setToProposerPreferences converts a set of signed data into a list of signed proposer preferences. func setToProposerPreferences(set core.SignedDataSet) ([]*gloas.SignedProposerPreferences, error) { var resp []*gloas.SignedProposerPreferences diff --git a/core/bcast/bcast_test.go b/core/bcast/bcast_test.go index 3c26f2725..de6b4535f 100644 --- a/core/bcast/bcast_test.go +++ b/core/bcast/bcast_test.go @@ -4,6 +4,7 @@ package bcast_test import ( "context" + "net/http" "testing" eth2api "github.com/attestantio/go-eth2-client/api" @@ -307,7 +308,8 @@ func proposerPreferences(t *testing.T, mock *beaconmock.Mock) test { asserted := make(chan struct{}) prefs := core.NewSignedProposerPreferences(testutil.RandomProposerPreferences()) - mock.SubmitProposerPreferencesFunc = func(_ context.Context, preferences []*gloas.SignedProposerPreferences) error { + mock.SubmitProposerPreferencesFunc = func(_ context.Context, opts *eth2api.SubmitProposerPreferencesOpts) error { + preferences := opts.Preferences require.Len(t, preferences, 1) require.Equal(t, prefs.SignedProposerPreferences, *preferences[0]) close(asserted) @@ -369,3 +371,112 @@ func syncCommitteeContribution(t *testing.T, mock *beaconmock.Mock) test { asserted: asserted, } } + +func TestBroadcastBuilderPreferences(t *testing.T) { + builderConfig := &gloas.BuilderConfig{ + Builders: []*gloas.BuilderEntry{ + {URL: []byte("https://Relay-A.example.com/gloas"), MaxExecutionPayment: 7}, + {URL: []byte("https://relay-b.example.com"), MaxExecutionPayment: 7}, + // A second builder on the same host, e.g. on another port, is authorized by the same auth. + {URL: []byte("https://relay-b.example.com:8443"), MaxExecutionPayment: 9}, + }, + } + + pubkey := testutil.RandomCorePubKey(t) + pubkeyBytes, err := pubkey.Bytes() + require.NoError(t, err) + + newAuth := func(data string) core.SignedBuilderRequestAuth { + return core.NewSignedBuilderRequestAuth(&gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte(data), Slot: 1}, + Signature: testutil.RandomEth2Signature(), + }) + } + + tests := []struct { + name string + auth core.SignedBuilderRequestAuth + submitErr error + builders []int // Indexes of the configured builders expected to be submitted. + err bool + }{ + { + name: "configured builder", + auth: newAuth("relay-a.example.com"), + builders: []int{0}, + }, + { + name: "builders sharing a host", + auth: newAuth("relay-b.example.com"), + builders: []int{1, 2}, + }, + { + name: "unconfigured builder", + auth: newAuth("relay-z.example.com"), + }, + { + // The beacon node submitted the other entries, so retrying would resubmit them. + name: "builder rejected", + auth: newAuth("relay-a.example.com"), + submitErr: ð2api.Error{StatusCode: http.StatusBadRequest, Data: []byte(`{"code":400,"message":"some entries failed","failures":[{"index":0,"message":"unknown proposer"}]}`)}, + builders: []int{0}, + }, + { + // The beacon node doesn't forward builder preferences, the auth still reaches the + // builder with the bid request at proposal time. + name: "unsupported by beacon node", + auth: newAuth("relay-a.example.com"), + submitErr: ð2api.Error{StatusCode: http.StatusNotFound}, + builders: []int{0}, + }, + { + name: "beacon node error", + auth: newAuth("relay-a.example.com"), + submitErr: ð2api.Error{StatusCode: http.StatusInternalServerError}, + builders: []int{0}, + err: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mock, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + var submitted []*gloas.BuilderPreferencesEntry + + mock.SubmitBuilderPreferencesFunc = func(_ context.Context, opts *eth2api.SubmitBuilderPreferencesOpts) error { + submitted = append(submitted, opts.Preferences...) + + return test.submitErr + } + + bcaster, err := bcast.New(t.Context(), mock, func() eth2wrap.ForkForkSchedule { return nil }, bcast.WithBuilderConfig(builderConfig)) + require.NoError(t, err) + + err = bcaster.Broadcast(t.Context(), core.NewBuilderPreferencesDuty(1), core.SignedDataSet{pubkey: test.auth}) + if test.err { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if len(test.builders) == 0 { + require.Empty(t, submitted) + return + } + + var expected []*gloas.BuilderPreferencesEntry + for _, i := range test.builders { + expected = append(expected, &gloas.BuilderPreferencesEntry{ + ProposerPubkey: eth2p0.BLSPubKey(pubkeyBytes), + URL: builderConfig.Builders[i].URL, + Auth: &test.auth.SignedBuilderRequestAuth, + MaxExecutionPayment: builderConfig.Builders[i].MaxExecutionPayment, + }) + } + + require.Equal(t, expected, submitted) + }) + } +} diff --git a/core/bcast/metrics.go b/core/bcast/metrics.go index 44e4f3555..fb0388cf2 100644 --- a/core/bcast/metrics.go +++ b/core/bcast/metrics.go @@ -26,6 +26,13 @@ var ( Help: "Duty broadcast delay since the expected duty submission in seconds by type", Buckets: []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 20, 30, 60}, }, []string{"duty"}) + + builderPreferencesRejected = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "core", + Subsystem: "bcast", + Name: "builder_preferences_rejected_total", + Help: "The total count of builder preferences entries rejected by builder URL", + }, []string{"builder"}) ) // instrumentDuty increments the duty counter. diff --git a/core/deadline.go b/core/deadline.go index db672b06b..9971e3e49 100644 --- a/core/deadline.go +++ b/core/deadline.go @@ -185,7 +185,7 @@ func (t dutyTiming) deadline(duty Duty) (time.Duration, bool) { case DutyPayloadAttestation: // Payload attestation messages are only accepted on gossip within their own slot. return t.slotDuration, true - case DutyProposerPreferences: + case DutyProposerPreferences, DutyBuilderPreferences: // Preferences are submitted from the start of the epoch before the proposal epoch // (the earliest the assignment is derivable) and are keyed by the proposal slot, // so entries live from submission (up to ~2 epochs early) until the slot passes. diff --git a/core/eth2signeddata.go b/core/eth2signeddata.go index c159d5063..460d64577 100644 --- a/core/eth2signeddata.go +++ b/core/eth2signeddata.go @@ -28,6 +28,7 @@ var ( _ Eth2SignedData = SyncCommitteeSelection{} _ Eth2SignedData = VersionedPayloadAttestationMessage{} _ Eth2SignedData = SignedProposerPreferences{} + _ Eth2SignedData = SignedBuilderRequestAuth{} _ Eth2SignedData = SignedExecutionPayloadEnvelope{} ) @@ -203,6 +204,22 @@ func (p SignedProposerPreferences) Epoch(ctx context.Context, eth2Cl eth2wrap.Cl return eth2util.EpochFromSlot(ctx, eth2Cl, p.Message.ProposalSlot) } +// Implement Eth2SignedData for SignedBuilderRequestAuth. + +func (SignedBuilderRequestAuth) DomainName() signing.DomainName { + return signing.DomainBuilderRequestAuth +} + +// Epoch returns the epoch of the authorized proposal slot. The builder request auth domain is +// fork-independent, so the epoch doesn't affect the signing root. +func (a SignedBuilderRequestAuth) Epoch(ctx context.Context, eth2Cl eth2wrap.Client) (eth2p0.Epoch, error) { + if a.Message == nil { + return 0, errors.New("nil builder request auth message") + } + + return eth2util.EpochFromSlot(ctx, eth2Cl, a.Message.Slot) +} + // Implement Eth2SignedData for SignedExecutionPayloadEnvelope. func (SignedExecutionPayloadEnvelope) DomainName() signing.DomainName { diff --git a/core/eth2signeddata_test.go b/core/eth2signeddata_test.go index 12b47117e..766923c8a 100644 --- a/core/eth2signeddata_test.go +++ b/core/eth2signeddata_test.go @@ -86,6 +86,10 @@ func TestVerifyEth2SignedData(t *testing.T) { name: "verify proposer preferences", data: core.NewSignedProposerPreferences(testutil.RandomProposerPreferences()), }, + { + name: "verify builder request auth", + data: core.NewSignedBuilderRequestAuth(testutil.RandomSignedBuilderRequestAuth()), + }, { name: "verify execution payload envelope", data: core.NewSignedExecutionPayloadEnvelope(testutil.RandomExecutionPayloadEnvelope()), diff --git a/core/fetcher/fetcher.go b/core/fetcher/fetcher.go index 16fbdba1c..d2cabcc67 100644 --- a/core/fetcher/fetcher.go +++ b/core/fetcher/fetcher.go @@ -24,6 +24,7 @@ import ( "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" "github.com/obolnetwork/charon/eth2util/eth2exp" ) @@ -74,7 +75,7 @@ type Fetcher struct { feeRecipientFunc func(core.PubKey) string subs []func(context.Context, core.Duty, core.UnsignedDataSet) error payloadContentsSubs []func(context.Context, core.VersionedProposal) error - aggSigDBFunc func(context.Context, core.Duty, core.PubKey, core.SubcommitteeIndex) (core.SignedData, error) + aggSigDBFunc func(context.Context, core.Duty, core.PubKey, core.AwaitOpts) (core.SignedData, error) awaitAttDataFunc func(ctx context.Context, slot, commIdx uint64) (*eth2p0.AttestationData, error) syncContributionV2Func func(slot uint64) bool builderEnabled bool @@ -362,7 +363,7 @@ func (f *Fetcher) Fetch(ctx context.Context, duty core.Duty, defSet core.DutyDef // RegisterAggSigDB registers a function to get resolved aggregated signed data from AggSigDB. // Note: This is not thread safe and should only be called *before* Fetch. -func (f *Fetcher) RegisterAggSigDB(fn func(context.Context, core.Duty, core.PubKey, core.SubcommitteeIndex) (core.SignedData, error)) { +func (f *Fetcher) RegisterAggSigDB(fn func(context.Context, core.Duty, core.PubKey, core.AwaitOpts) (core.SignedData, error)) { f.aggSigDBFunc = fn } @@ -475,7 +476,7 @@ func (f *Fetcher) fetchAggregatorData(ctx context.Context, slot uint64, defSet c } // Query AggSigDB for DutyPrepareAggregator to get beacon committee selections. - prepAggData, err := f.aggSigDBFunc(ctx, core.NewPrepareAggregatorDuty(slot), pubkey, 0) + prepAggData, err := f.aggSigDBFunc(ctx, core.NewPrepareAggregatorDuty(slot), pubkey, core.AwaitOpts{}) if err != nil { return core.UnsignedDataSet{}, err } @@ -621,7 +622,7 @@ func (f *Fetcher) fetchEPBSProposerData(ctx context.Context, slot uint64, defSet Slot: eth2p0.Slot(slot), RandaoReveal: randao, Graffiti: f.graffitiBuilder.GetGraffiti(pubkey), - BuilderConfig: f.builderConfig, + BuilderConfig: f.builderConfigFor(ctx, slot, pubkey), IncludePayload: &includePayload, } @@ -677,9 +678,82 @@ func (f *Fetcher) fetchEPBSProposerData(ctx context.Context, slot uint64, defSet return resp, nil } +// builderAuthTimeout bounds how long a proposal waits for the aggregated builder request auths. +// Validator clients sign them ahead of the proposal, so they are usually aggregated already. +const builderAuthTimeout = 500 * time.Millisecond + +// builderConfigFor returns the builder configuration of the validator's proposal: the configured +// builders with their aggregated builder request auths. A builder whose auth isn't aggregated in +// time is left out, since the beacon node can't request bids from it without one. +func (f *Fetcher) builderConfigFor(ctx context.Context, slot uint64, pubkey core.PubKey) *gloas.BuilderConfig { + if f.builderConfig == nil || len(f.builderConfig.Builders) == 0 || f.aggSigDBFunc == nil { + return f.builderConfig + } + + ctx, cancel := context.WithTimeout(ctx, builderAuthTimeout) + defer cancel() + + entries := make([]*gloas.BuilderEntry, len(f.builderConfig.Builders)) + + var wg sync.WaitGroup + for i, entry := range f.builderConfig.Builders { + wg.Go(func() { + auth, err := f.awaitBuilderAuth(ctx, slot, pubkey, entry) + if err != nil { + log.Warn(ctx, "Requesting proposal without builder, its request auth isn't aggregated", err, + z.U64("slot", slot), z.Str("builder", string(entry.URL))) + + return + } + + withAuth := *entry + withAuth.BuilderPubkeys = slices.Clone(entry.BuilderPubkeys) + withAuth.Auth = auth + entries[i] = &withAuth + }) + } + + wg.Wait() + + // Keep the builders whose auth resolved, in configured order. + var builders []*gloas.BuilderEntry + + for _, entry := range entries { + if entry != nil { + builders = append(builders, entry) + } + } + + return &gloas.BuilderConfig{ + MinBid: f.builderConfig.MinBid, + BuilderBoostFactor: f.builderConfig.BuilderBoostFactor, + Builders: builders, + } +} + +// awaitBuilderAuth returns the aggregated builder request auth of the validator's proposal for the builder. +func (f *Fetcher) awaitBuilderAuth(ctx context.Context, slot uint64, pubkey core.PubKey, entry *gloas.BuilderEntry) (*gloas.SignedBuilderRequestAuth, error) { + builderHost, err := eth2util.BuilderAuthData(string(entry.URL)) + if err != nil { + return nil, err + } + + signed, err := f.aggSigDBFunc(ctx, core.NewBuilderPreferencesDuty(slot), pubkey, core.AwaitOpts{BuilderHost: string(builderHost)}) + if err != nil { + return nil, err + } + + auth, ok := signed.(core.SignedBuilderRequestAuth) + if !ok { + return nil, errors.New("invalid builder request auth") + } + + return &auth.SignedBuilderRequestAuth, nil +} + // fetchRandao returns the previously aggregated randao reveal for the slot from the AggSigDB. func (f *Fetcher) fetchRandao(ctx context.Context, slot uint64, pubkey core.PubKey) (eth2p0.BLSSignature, error) { - randaoData, err := f.aggSigDBFunc(ctx, core.NewRandaoDuty(slot), pubkey, 0) + randaoData, err := f.aggSigDBFunc(ctx, core.NewRandaoDuty(slot), pubkey, core.AwaitOpts{}) if err != nil { return eth2p0.BLSSignature{}, err } @@ -814,7 +888,7 @@ type syncContribKey struct { // aggregator for the subcommittee. func (f *Fetcher) fetchSubcommContribution(ctx context.Context, slot uint64, pubkey core.PubKey, subcommIdx core.SubcommitteeIndex, contribByKey map[syncContribKey]core.SyncContribution) (core.SyncContribution, bool, error) { // Query AggSigDB for DutyPrepareSyncContribution to get the sync committee selection. - selectionData, err := f.aggSigDBFunc(ctx, core.NewPrepareSyncContributionDuty(slot), pubkey, subcommIdx) + selectionData, err := f.aggSigDBFunc(ctx, core.NewPrepareSyncContributionDuty(slot), pubkey, core.AwaitOpts{SubcommIdx: subcommIdx}) if err != nil { return core.SyncContribution{}, false, err } @@ -833,7 +907,7 @@ func (f *Fetcher) fetchSubcommContribution(ctx context.Context, slot uint64, pub } // Query AggSigDB for DutySyncMessage to get the beacon block root. - syncMsgData, err := f.aggSigDBFunc(ctx, core.NewSyncMessageDuty(slot), pubkey, 0) + syncMsgData, err := f.aggSigDBFunc(ctx, core.NewSyncMessageDuty(slot), pubkey, core.AwaitOpts{}) if err != nil { return core.SyncContribution{}, false, err } diff --git a/core/fetcher/fetcher_test.go b/core/fetcher/fetcher_test.go index 429b7b90a..ff5ca9304 100644 --- a/core/fetcher/fetcher_test.go +++ b/core/fetcher/fetcher_test.go @@ -235,7 +235,7 @@ func TestFetchAggregator(t *testing.T) { } fetch := mustCreateFetcher(t, bmock) - fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { require.Equal(t, core.NewPrepareAggregatorDuty(slot), duty) return signedCommSubByPubKey[key], nil @@ -374,7 +374,7 @@ func TestFetchBlocks(t *testing.T) { duty := core.NewProposerDuty(slot) fetch := mustCreateFetcherWithAddressAndGraffiti(t, bmock, feeRecipientAddr, graffitiBuilder) - fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { return randaoByPubKey[key], nil }) @@ -450,7 +450,7 @@ func TestFetchEPBSBlocks(t *testing.T) { func() eth2wrap.ForkForkSchedule { return eth2wrap.ForkForkSchedule{eth2wrap.Gloas: {Epoch: 0}} }, 1, &gloas.BuilderConfig{}, false) require.NoError(t, err) - fetch.RegisterAggSigDB(func(context.Context, core.Duty, core.PubKey, core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(context.Context, core.Duty, core.PubKey, core.AwaitOpts) (core.SignedData, error) { return randao, nil }) @@ -681,7 +681,7 @@ func TestFetchSyncContribution(t *testing.T) { fetch := mustCreateFetcher(t, bmock) // All peers on v1.11+ -> plural SyncContributions encoding. fetch.RegisterSyncContributionV2(func(uint64) bool { return true }) - fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { if duty.Type == core.DutyPrepareSyncContribution { require.Equal(t, core.NewPrepareSyncContributionDuty(slot), duty) return commSelectionsByPubkey[key], nil @@ -753,7 +753,7 @@ func TestFetchSyncContribution(t *testing.T) { fetch := mustCreateFetcher(t, bmock) // No RegisterSyncContribV2 → gate off → single (pre-v1.11) encoding. - fetch.RegisterAggSigDB(func(_ context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(_ context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { if duty.Type == core.DutyPrepareSyncContribution { return commSelectionsByPubkey[key], nil } @@ -821,10 +821,10 @@ func TestFetchSyncContribution(t *testing.T) { fetch := mustCreateFetcher(t, bmock) fetch.RegisterSyncContributionV2(func(uint64) bool { return true }) // Plural encoding. - fetch.RegisterAggSigDB(func(_ context.Context, duty core.Duty, _ core.PubKey, subcommIdx core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(_ context.Context, duty core.Duty, _ core.PubKey, awaitOpts core.AwaitOpts) (core.SignedData, error) { switch duty.Type { case core.DutyPrepareSyncContribution: - return selectionsBySubcomm[subcommIdx], nil + return selectionsBySubcomm[awaitOpts.SubcommIdx], nil case core.DutySyncMessage: return syncMsgsByPubkey[pubkey], nil default: @@ -854,7 +854,7 @@ func TestFetchSyncContribution(t *testing.T) { require.NoError(t, err) fetch := mustCreateFetcher(t, bmock) - fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { if duty.Type == core.DutyPrepareSyncContribution { require.Equal(t, core.NewPrepareSyncContributionDuty(slot), duty) @@ -940,7 +940,7 @@ func TestFetchSyncContribution(t *testing.T) { fetch := mustCreateFetcher(t, bmock) fetch.RegisterSyncContributionV2(func(uint64) bool { return true }) - fetch.RegisterAggSigDB(func(_ context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(_ context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { switch duty.Type { case core.DutyPrepareSyncContribution: return selectionsByPubkey[key], nil @@ -980,7 +980,7 @@ func TestFetchSyncContribution(t *testing.T) { require.NoError(t, err) fetch := mustCreateFetcher(t, bmock) - fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, key core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { return nil, errors.New("error") }) @@ -1509,3 +1509,84 @@ func TestFetchOnlyEvictsStaleSlots(t *testing.T) { require.NoError(t, err) require.True(t, called) } + +// TestFetchEPBSBuilderAuths verifies that the configured builders are requested with their +// aggregated builder request auths, and that a builder whose aggregate doesn't resolve in time is +// left out rather than delaying the proposal. +func TestFetchEPBSBuilderAuths(t *testing.T) { + const slot = 1 + + pubkey := testutil.RandomCorePubKey(t) + defSet := core.DutyDefinitionSet{ + pubkey: core.NewProposerDefinition(ð2v1.ProposerDuty{Slot: slot, ValidatorIndex: 2}), + } + + template := &gloas.BuilderConfig{ + MinBid: 3, + BuilderBoostFactor: 90, + Builders: []*gloas.BuilderEntry{ + {URL: []byte("https://Relay-A.example.com/gloas"), MaxExecutionPayment: 7, MinBid: 3, BuilderBoostFactor: 90}, + {URL: []byte("https://relay-b.example.com"), MaxExecutionPayment: 7, MinBid: 3, BuilderBoostFactor: 90}, + }, + } + + authA := core.NewSignedBuilderRequestAuth(&gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte("relay-a.example.com"), Slot: slot}, + Signature: testutil.RandomEth2Signature(), + }) + + bmock, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + var requested *gloas.BuilderConfig + + bmock.EPBSProposalFunc = func(_ context.Context, opts *eth2api.EPBSProposalOpts) (*eth2api.VersionedEPBSProposal, error) { + requested = opts.BuilderConfig + + block := testutil.RandomGloasBeaconBlock() + block.Slot = opts.Slot + block.Body.RANDAOReveal = opts.RandaoReveal + + return ð2api.VersionedEPBSProposal{Version: eth2spec.DataVersionGloas, Gloas: block}, nil + } + + fetch, err := fetcher.New(t.Context(), bmock, func(core.PubKey) string { + return "0x0000000000000000000000000000000000000000" + }, false, &fetcher.GraffitiBuilder{}, + func() eth2wrap.ForkForkSchedule { return eth2wrap.ForkForkSchedule{eth2wrap.Gloas: {Epoch: 0}} }, 1, template, false) + require.NoError(t, err) + + // Only builder A's aggregate resolves, builder B's never does. + fetch.RegisterAggSigDB(func(ctx context.Context, duty core.Duty, pk core.PubKey, awaitOpts core.AwaitOpts) (core.SignedData, error) { + if duty.Type != core.DutyBuilderPreferences { + return testutil.RandomCoreSignature(), nil + } + + require.Equal(t, core.NewBuilderPreferencesDuty(slot), duty) + require.Equal(t, pubkey, pk) + + if awaitOpts.BuilderHost == "relay-a.example.com" { + return authA, nil + } + + <-ctx.Done() + + return nil, ctx.Err() + }) + + fetch.Subscribe(func(context.Context, core.Duty, core.UnsignedDataSet) error { return nil }) + + require.NoError(t, fetch.Fetch(t.Context(), core.NewProposerDuty(slot), defSet)) + + require.NotNil(t, requested) + require.EqualValues(t, 3, requested.MinBid) + require.EqualValues(t, 90, requested.BuilderBoostFactor) + require.Len(t, requested.Builders, 1) + require.Equal(t, []byte("https://Relay-A.example.com/gloas"), requested.Builders[0].URL) + require.EqualValues(t, 7, requested.Builders[0].MaxExecutionPayment) + require.Equal(t, &authA.SignedBuilderRequestAuth, requested.Builders[0].Auth) + + // The template is left untouched for later proposals. + require.Nil(t, template.Builders[0].Auth) + require.Len(t, template.Builders, 2) +} diff --git a/core/interfaces.go b/core/interfaces.go index c8f4fed6e..97b157542 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -45,12 +45,8 @@ type Fetcher interface { SubscribePayloadContents(func(context.Context, VersionedProposal) error) // RegisterAggSigDB registers a function to get resolved aggregated - // signed data from the AggSigDB (e.g., randao reveals). The SubcommitteeIndex - // selects the sync subcommittee for sync-committee aggregator duties - // (DutyPrepareSyncContribution, DutySyncContribution) since a validator can - // have a distinct aggregated signature per subcommittee in a slot; it is 0 - // for all other duties. - RegisterAggSigDB(func(context.Context, Duty, PubKey, SubcommitteeIndex) (SignedData, error)) + // signed data from the AggSigDB (e.g., randao reveals), see AwaitOpts. + RegisterAggSigDB(func(context.Context, Duty, PubKey, AwaitOpts) (SignedData, error)) // RegisterAwaitAttData registers a function to get attestation data from DutyDB. RegisterAwaitAttData(func(ctx context.Context, slot uint64, commIdx uint64) (*eth2p0.AttestationData, error)) @@ -202,7 +198,7 @@ type ValidatorAPI interface { // The SubcommitteeIndex selects the sync subcommittee for sync-committee aggregator duties // (DutyPrepareSyncContribution, DutySyncContribution), where a validator can have a distinct // aggregated signature per subcommittee in a slot; it is 0 for all other duties. - RegisterAwaitAggSigDB(func(context.Context, Duty, PubKey, SubcommitteeIndex) (SignedData, error)) + RegisterAwaitAggSigDB(func(context.Context, Duty, PubKey, AwaitOpts) (SignedData, error)) // Subscribe registers a function to store partially signed data sets. Subscribe(func(context.Context, Duty, ParSignedDataSet) error) @@ -245,22 +241,27 @@ type SigAgg interface { Subscribe(func(context.Context, Duty, SignedDataSet) error) } +// AwaitOpts identifies the awaited aggregate for duty families with several aggregates per duty +// and validator. The zero value addresses the single aggregate of all other duties. +type AwaitOpts struct { + // SubcommIdx selects the sync subcommittee for the sync-committee aggregator duties + // (DutyPrepareSyncContribution, DutySyncContribution), where a validator can have a + // distinct aggregate per subcommittee in a slot (see IsSyncSubcommitteeDuty). + SubcommIdx SubcommitteeIndex + // BuilderHost selects the builder for DutyBuilderPreferences, where a proposer has a + // distinct aggregated request auth per builder it authorizes. It is the builder's + // hostname as signed in the auth, e.g. "relay-a.example.com". + BuilderHost string +} + // AggSigDB persists aggregated signed duty data. type AggSigDB interface { // Store stores aggregated signed duty data set. Store(context context.Context, duty Duty, data SignedDataSet) error - // Await blocks and returns the aggregated signed duty data when available. - // subcommIdx selects the sync subcommittee for sync-committee aggregator duties - // (DutyPrepareSyncContribution, DutySyncContribution), where a validator can have - // a distinct aggregated signature per subcommittee in a slot; it is 0 for all - // other duties (see IsSyncSubcommitteeDuty). - // - // TODO(kalo): Refactor the query API so per-duty-family fields aren't forced on every - // caller: subcommIdx only applies to the sync-committee aggregator duties, all other - // callers pass a meaningless 0, and duty families whose identity exceeds these - // parameters (proposer preferences) cannot be addressed at all. - Await(context context.Context, duty Duty, pubKey PubKey, subcommIdx SubcommitteeIndex) (SignedData, error) + // Await blocks and returns the aggregated signed duty data when available. The opts + // identify the aggregate of duty families with several per duty and validator, see AwaitOpts. + Await(context context.Context, duty Duty, pubKey PubKey, awaitOpts AwaitOpts) (SignedData, error) // Run runs AggSigDB lifecycle until context is cancelled. Run(context context.Context) @@ -322,7 +323,7 @@ type wireFuncs struct { FetcherFetchOnly func(context.Context, Duty, DutyDefinitionSet, string, eth2p0.Root) error FetcherSubscribe func(func(context.Context, Duty, UnsignedDataSet) error) FetcherSubscribePayloadContents func(func(context.Context, VersionedProposal) error) - FetcherRegisterAggSigDB func(func(context.Context, Duty, PubKey, SubcommitteeIndex) (SignedData, error)) + FetcherRegisterAggSigDB func(func(context.Context, Duty, PubKey, AwaitOpts) (SignedData, error)) FetcherRegisterAwaitAttData func(func(ctx context.Context, slot uint64, commIdx uint64) (*eth2p0.AttestationData, error)) ConsensusParticipate func(context.Context, Duty) error ConsensusPropose func(context.Context, Duty, UnsignedDataSet) error @@ -348,7 +349,7 @@ type wireFuncs struct { VAPIRegisterGetDutyDefinition func(func(context.Context, Duty) (DutyDefinitionSet, error)) VAPIRegisterPubKeyByAttestation func(func(ctx context.Context, slot, commIdx, valIdx uint64) (PubKey, error)) VAPIRegisterAwaitAggAttestation func(func(ctx context.Context, slot uint64, attestationRoot eth2p0.Root, committeeIndex eth2p0.CommitteeIndex) (*eth2spec.VersionedAttestation, error)) - VAPIRegisterAwaitAggSigDB func(func(context.Context, Duty, PubKey, SubcommitteeIndex) (SignedData, error)) + VAPIRegisterAwaitAggSigDB func(func(context.Context, Duty, PubKey, AwaitOpts) (SignedData, error)) VAPIRegisterAwaitPayloadAttData func(func(ctx context.Context, slot uint64) (*eth2spec.VersionedPayloadAttestationData, bool, error)) VAPISubscribe func(func(context.Context, Duty, ParSignedDataSet) error) ParSigDBStoreInternal func(context.Context, Duty, ParSignedDataSet) error @@ -360,7 +361,7 @@ type wireFuncs struct { SigAggAggregate func(context.Context, Duty, map[PubKey][]ParSignedData) error SigAggSubscribe func(func(context.Context, Duty, SignedDataSet) error) AggSigDBStore func(context.Context, Duty, SignedDataSet) error - AggSigDBAwait func(context.Context, Duty, PubKey, SubcommitteeIndex) (SignedData, error) + AggSigDBAwait func(context.Context, Duty, PubKey, AwaitOpts) (SignedData, error) BroadcasterBroadcast func(context.Context, Duty, SignedDataSet) error } diff --git a/core/parsigdb/memory.go b/core/parsigdb/memory.go index 0224f602d..639926762 100644 --- a/core/parsigdb/memory.go +++ b/core/parsigdb/memory.go @@ -43,14 +43,15 @@ type MemDBMetadata struct { // NewMemDB returns a new in-memory partial signature database instance. func NewMemDB(threshold int, deadliner core.Deadliner, metadata MemDBMetadata) *MemDB { return &MemDB{ - generalDuties: newDutyStore[generalKey](), - subCommDuties: newDutyStore[subCommKey](), - propPrefDuties: newDutyStore[propPrefKey](), - persistedDuties: newDutyStore[generalKey](), - exemptEntries: make(map[exemptEntryKey][]generalKey), - threshold: threshold, - deadliner: deadliner, - metadata: metadata, + generalDuties: newDutyStore[generalKey](), + subCommDuties: newDutyStore[subCommKey](), + propPrefDuties: newDutyStore[propPrefKey](), + builderPrefDuties: newDutyStore[builderPrefKey](), + persistedDuties: newDutyStore[generalKey](), + exemptEntries: make(map[exemptEntryKey][]generalKey), + threshold: threshold, + deadliner: deadliner, + metadata: metadata, } } @@ -71,6 +72,9 @@ type MemDB struct { // propPrefDuties holds proposer preferences, additionally keyed by the fields // that may legitimately change on resubmission. propPrefDuties *dutyStore[propPrefKey] + // builderPrefDuties holds builder request auths, additionally keyed by the builder's hostname + // since a proposer authorizes several builders for the same proposal slot. + builderPrefDuties *dutyStore[builderPrefKey] // persistedDuties holds exempt duties (exits, builder registrations) which the // deadliner never trims; they are capped via exemptEntries instead. persistedDuties *dutyStore[generalKey] @@ -199,6 +203,7 @@ func (db *MemDB) Trim(ctx context.Context) { db.generalDuties.trim(duty) db.subCommDuties.trim(duty) db.propPrefDuties.trim(duty) + db.builderPrefDuties.trim(duty) db.mu.Unlock() } } @@ -267,6 +272,14 @@ func (db *MemDB) store(ctx context.Context, duty core.Duty, pubkey core.PubKey, } sigs, added, err = db.propPrefDuties.store(duty, pubkey, k, value, true) + case duty.Type == core.DutyBuilderPreferences: + auth, ok := value.SignedData.(core.SignedBuilderRequestAuth) + if !ok || auth.Message == nil { + return nil, false, errors.New("invalid builder request auth data") + } + + k := builderPrefKey{Duty: duty, PubKey: pubkey, BuilderHost: string(auth.Message.Data)} + sigs, added, err = db.builderPrefDuties.store(duty, pubkey, k, value, true) default: sigs, added, err = db.generalDuties.store(duty, pubkey, generalKey{Duty: duty, PubKey: pubkey}, value, true) } @@ -380,6 +393,15 @@ type propPrefKey struct { TargetGasLimit uint64 } +// builderPrefKey additionally carries the builder's hostname, the identity signed in the request +// auth, e.g. "relay-a.example.com" for the builder URL https://relay-a.example.com: a proposer +// authorizes each builder it requests bids from separately for the same proposal slot. +type builderPrefKey struct { + Duty core.Duty + PubKey core.PubKey + BuilderHost string +} + // newDutyStore returns a new empty duty store. func newDutyStore[K comparable]() *dutyStore[K] { return &dutyStore[K]{ diff --git a/core/parsigdb/memory_internal_test.go b/core/parsigdb/memory_internal_test.go index 491677f3a..c88dc3fa9 100644 --- a/core/parsigdb/memory_internal_test.go +++ b/core/parsigdb/memory_internal_test.go @@ -333,3 +333,64 @@ func TestMemDBProposerPreferencesReorg(t *testing.T) { require.NoError(t, err) require.Equal(t, rootB, root) } + +// TestMemDBBuilderRequestAuthPerBuilder verifies that a share's builder request auths for several +// builders of the same proposal slot don't collide, and that each builder's auth aggregates to +// threshold independently. +func TestMemDBBuilderRequestAuthPerBuilder(t *testing.T) { + const th = 2 + + db := NewMemDB(th, newTestDeadliner(), NewMemDBMetadata(eth2util.Mainnet.SlotDuration, time.Unix(eth2util.Mainnet.GenesisTimestamp, 0))) + + var thresholdSets []map[core.PubKey][]core.ParSignedData + + db.SubscribeThreshold(func(_ context.Context, _ core.Duty, set map[core.PubKey][]core.ParSignedData) error { + thresholdSets = append(thresholdSets, set) + + return nil + }) + + pubkey := testutil.RandomCorePubKey(t) + + authA := testutil.RandomSignedBuilderRequestAuth() + authA.Message.Data = []byte("relay-a.example.com") + duty := core.NewBuilderPreferencesDuty(uint64(authA.Message.Slot)) + + authB := testutil.RandomSignedBuilderRequestAuth() + authB.Message.Data = []byte("relay-b.example.com") + authB.Message.Slot = authA.Message.Slot + + store := func(auth *gloas.SignedBuilderRequestAuth, shareIdx int) error { + return db.StoreExternal(context.Background(), duty, core.ParSignedDataSet{ + pubkey: core.NewPartialSignedBuilderRequestAuth(auth, shareIdx), + }) + } + + // Share 1 authorizes both builders: the second must not error as mismatching. + require.NoError(t, store(authA, 1)) + require.NoError(t, store(authB, 1)) + require.Empty(t, thresholdSets) + + // Share 2 authorizes builder B: threshold reached for B only. + require.NoError(t, store(authB, 2)) + require.Len(t, thresholdSets, 1) + require.Len(t, thresholdSets[0][pubkey], th) + + root, err := thresholdSets[0][pubkey][0].MessageRoot() + require.NoError(t, err) + + rootB, err := core.NewSignedBuilderRequestAuth(authB).MessageRoot() + require.NoError(t, err) + require.Equal(t, rootB, root) + + // Share 2 authorizes builder A: threshold reached for A independently. + require.NoError(t, store(authA, 2)) + require.Len(t, thresholdSets, 2) + + root, err = thresholdSets[1][pubkey][0].MessageRoot() + require.NoError(t, err) + + rootA, err := core.NewSignedBuilderRequestAuth(authA).MessageRoot() + require.NoError(t, err) + require.Equal(t, rootA, root) +} diff --git a/core/proto.go b/core/proto.go index 188ea34c8..d00277e5a 100644 --- a/core/proto.go +++ b/core/proto.go @@ -162,6 +162,13 @@ func ParSignedDataFromProto(typ DutyType, data *pbv1.ParSignedData) (_ ParSigned return ParSignedData{}, errors.Wrap(err, "unmarshal signed proposer preferences") } + signedData = s + case DutyBuilderPreferences: + var s SignedBuilderRequestAuth + if err := unmarshal(data.GetData(), &s); err != nil { + return ParSignedData{}, errors.Wrap(err, "unmarshal signed builder request auth") + } + signedData = s case DutyExecutionPayloadEnvelope: var s SignedExecutionPayloadEnvelope diff --git a/core/proto_test.go b/core/proto_test.go index 9ec634f7a..c067743a5 100644 --- a/core/proto_test.go +++ b/core/proto_test.go @@ -104,6 +104,10 @@ func TestParSignedDataSetProto(t *testing.T) { Type: core.DutyProposerPreferences, Data: core.NewSignedProposerPreferences(testutil.RandomProposerPreferences()), }, + { + Type: core.DutyBuilderPreferences, + Data: core.NewSignedBuilderRequestAuth(testutil.RandomSignedBuilderRequestAuth()), + }, { Type: core.DutyExecutionPayloadEnvelope, Data: core.NewSignedExecutionPayloadEnvelope(testutil.RandomExecutionPayloadEnvelope()), diff --git a/core/serialise_test.go b/core/serialise_test.go index 20d8a372c..53aed3b48 100644 --- a/core/serialise_test.go +++ b/core/serialise_test.go @@ -40,6 +40,7 @@ var coreTypeFuncs = []func() any{ func() any { return new(core.VersionedPayloadAttestationMessage) }, func() any { return new(core.SignedProposerPreferences) }, func() any { return new(core.SignedExecutionPayloadEnvelope) }, + func() any { return new(core.SignedBuilderRequestAuth) }, } //go:generate go test . -run=TestJSONSerialisation -update diff --git a/core/signeddata.go b/core/signeddata.go index a16f85141..f27365fed 100644 --- a/core/signeddata.go +++ b/core/signeddata.go @@ -52,6 +52,7 @@ var ( _ SignedData = SyncCommitteeSelection{} _ SignedData = VersionedPayloadAttestationMessage{} _ SignedData = SignedProposerPreferences{} + _ SignedData = SignedBuilderRequestAuth{} _ SignedData = SignedExecutionPayloadEnvelope{} // Some types support SSZ marshalling and unmarshalling. @@ -64,6 +65,7 @@ var ( _ sszMarshaler = SignedSyncContributionAndProof{} _ sszMarshaler = VersionedPayloadAttestationMessage{} _ sszMarshaler = SignedProposerPreferences{} + _ sszMarshaler = SignedBuilderRequestAuth{} _ sszMarshaler = SignedExecutionPayloadEnvelope{} _ sszUnmarshaler = new(VersionedSignedProposal) _ sszUnmarshaler = new(VersionedAttestation) @@ -74,6 +76,7 @@ var ( _ sszUnmarshaler = new(SignedSyncContributionAndProof) _ sszUnmarshaler = new(VersionedPayloadAttestationMessage) _ sszUnmarshaler = new(SignedProposerPreferences) + _ sszUnmarshaler = new(SignedBuilderRequestAuth) ) // SigFromETH2 returns a new signature from eth2 phase0 BLSSignature. @@ -2427,6 +2430,92 @@ func (p *SignedProposerPreferences) UnmarshalSSZ(b []byte) error { return p.SignedProposerPreferences.UnmarshalSSZ(b) } +// BuilderRequestAuth: https://github.com/ethereum/builder-specs/blob/main/specs/gloas/validator.md#builderrequestauth. +// From the gloas fork, a proposer authorizes each builder it requests bids from by signing the +// builder's auth data and the proposal slot. + +// NewSignedBuilderRequestAuth is a convenience function which returns a new signed SignedBuilderRequestAuth. +func NewSignedBuilderRequestAuth(data *gloas.SignedBuilderRequestAuth) SignedBuilderRequestAuth { + return SignedBuilderRequestAuth{SignedBuilderRequestAuth: *data} +} + +// NewPartialSignedBuilderRequestAuth is a convenience function which returns a new partially signed SignedBuilderRequestAuth. +func NewPartialSignedBuilderRequestAuth(data *gloas.SignedBuilderRequestAuth, shareIdx int) ParSignedData { + return ParSignedData{ + SignedData: NewSignedBuilderRequestAuth(data), + ShareIdx: shareIdx, + } +} + +// SignedBuilderRequestAuth wraps gloas.SignedBuilderRequestAuth and implements SignedData. +type SignedBuilderRequestAuth struct { + gloas.SignedBuilderRequestAuth +} + +// MessageRoot returns the hash tree root of the builder request auth message, which is the object +// signed over with DOMAIN_BUILDER_REQUEST_AUTH. +func (a SignedBuilderRequestAuth) MessageRoot() ([32]byte, error) { + if a.Message == nil { + return [32]byte{}, errors.New("nil builder request auth message") + } + + return a.Message.HashTreeRoot() +} + +func (a SignedBuilderRequestAuth) Signature() Signature { + return SigFromETH2(a.SignedBuilderRequestAuth.Signature) +} + +func (a SignedBuilderRequestAuth) SetSignature(sig Signature) (SignedData, error) { + resp, err := a.clone() + if err != nil { + return nil, err + } + + resp.SignedBuilderRequestAuth.Signature = sig.ToETH2() + + return resp, nil +} + +func (a SignedBuilderRequestAuth) Clone() (SignedData, error) { + return a.clone() +} + +func (a SignedBuilderRequestAuth) clone() (SignedBuilderRequestAuth, error) { + var resp SignedBuilderRequestAuth + + err := cloneSSZMarshaler(a, &resp) + if err != nil { + return SignedBuilderRequestAuth{}, errors.Wrap(err, "clone signed builder request auth") + } + + return resp, nil +} + +func (a SignedBuilderRequestAuth) MarshalJSON() ([]byte, error) { + return a.SignedBuilderRequestAuth.MarshalJSON() +} + +func (a *SignedBuilderRequestAuth) UnmarshalJSON(input []byte) error { + return a.SignedBuilderRequestAuth.UnmarshalJSON(input) +} + +func (a SignedBuilderRequestAuth) MarshalSSZ() ([]byte, error) { + return a.SignedBuilderRequestAuth.MarshalSSZ() +} + +func (a SignedBuilderRequestAuth) MarshalSSZTo(dst []byte) ([]byte, error) { + return a.SignedBuilderRequestAuth.MarshalSSZTo(dst) +} + +func (a SignedBuilderRequestAuth) SizeSSZ() int { + return a.SignedBuilderRequestAuth.SizeSSZ() +} + +func (a *SignedBuilderRequestAuth) UnmarshalSSZ(b []byte) error { + return a.SignedBuilderRequestAuth.UnmarshalSSZ(b) +} + // From the gloas fork, a self-building proposer reveals its execution payload via a // SignedExecutionPayloadEnvelope. The wrapped type is the stateless submission contents (envelope // plus blobs and proofs) so blobs ride through aggregation; aggregation only touches the message. diff --git a/core/signeddata_test.go b/core/signeddata_test.go index 51a3d223f..1c5008ca9 100644 --- a/core/signeddata_test.go +++ b/core/signeddata_test.go @@ -471,6 +471,10 @@ func TestSignedDataSetSignature(t *testing.T) { name: "signed proposer preferences", data: core.NewSignedProposerPreferences(testutil.RandomProposerPreferences()), }, + { + name: "signed builder request auth", + data: core.NewSignedBuilderRequestAuth(testutil.RandomSignedBuilderRequestAuth()), + }, { name: "signed execution payload envelope", data: core.NewSignedExecutionPayloadEnvelope(testutil.RandomExecutionPayloadEnvelope()), diff --git a/core/ssz_test.go b/core/ssz_test.go index 0e104b6d7..8b897f7e0 100644 --- a/core/ssz_test.go +++ b/core/ssz_test.go @@ -90,6 +90,7 @@ func TestSSZ(t *testing.T) { {zero: func() any { return new(core.VersionedPayloadAttestationMessage) }}, {zero: func() any { return new(core.SignedProposerPreferences) }}, {zero: func() any { return new(core.SignedExecutionPayloadEnvelope) }}, + {zero: func() any { return new(core.SignedBuilderRequestAuth) }}, } f := testutil.NewEth2Fuzzer(t, 0) diff --git a/core/testdata/TestJSONSerialisation_SignedBuilderRequestAuth.json.golden b/core/testdata/TestJSONSerialisation_SignedBuilderRequestAuth.json.golden new file mode 100644 index 000000000..5d738606c --- /dev/null +++ b/core/testdata/TestJSONSerialisation_SignedBuilderRequestAuth.json.golden @@ -0,0 +1,7 @@ +{ + "message": { + "data": "0x0272908d45b0164a", + "slot": "4031592227175403038" + }, + "signature": "0x2456f0eff11f99ef25258727c8b2922e22f5563d39490544d0ec4c0e68f23b572665b0e60f46fd4ffbf4ebe0530c03f2ecd45fac9d4aef1d6a649ec7799f34798eb91ff4c4fe748e21819c51945e7ed3df1fb0b5a4e0e895804813d1f4a80680" +} \ No newline at end of file diff --git a/core/testdata/TestSSZSerialisation_SignedBuilderRequestAuth.ssz.golden b/core/testdata/TestSSZSerialisation_SignedBuilderRequestAuth.ssz.golden new file mode 100644 index 000000000..c71e6f87a Binary files /dev/null and b/core/testdata/TestSSZSerialisation_SignedBuilderRequestAuth.ssz.golden differ diff --git a/core/tracing.go b/core/tracing.go index b99ba54ed..30b518228 100644 --- a/core/tracing.go +++ b/core/tracing.go @@ -158,11 +158,11 @@ func WithTracing() WireOption { return withSpanStatus(span, clone.AggSigDBStore(ctx, duty, set)) } - w.AggSigDBAwait = func(parent context.Context, duty Duty, key PubKey, subcommIdx SubcommitteeIndex) (SignedData, error) { + w.AggSigDBAwait = func(parent context.Context, duty Duty, key PubKey, awaitOpts AwaitOpts) (SignedData, error) { ctx, span := tracer.Start(parent, "core/aggsigdb.Await") defer span.End() - sd, err := clone.AggSigDBAwait(ctx, duty, key, subcommIdx) + sd, err := clone.AggSigDBAwait(ctx, duty, key, awaitOpts) return sd, withSpanStatus(span, err) } diff --git a/core/tracker/metrics.go b/core/tracker/metrics.go index 1fc856361..636ac19f3 100644 --- a/core/tracker/metrics.go +++ b/core/tracker/metrics.go @@ -123,4 +123,12 @@ var ( Name: "attestation_success_total", Help: "Total number of successful attestations for the slot (counts individual attestations, not duties)", }) + + builderRequestAuths = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "core", + Subsystem: "tracker", + Name: "builder_request_auths_total", + Help: "Total number of partially signed builder request auths by peer and builder host, " + + "those for builders not configured on this node counted as unknown", + }, []string{"peer", "builder"}) ) diff --git a/core/tracker/tracker.go b/core/tracker/tracker.go index e597b1219..041b3f8ae 100644 --- a/core/tracker/tracker.go +++ b/core/tracker/tracker.go @@ -112,10 +112,34 @@ type Tracker struct { // participationReporter instruments duty peer participation. participationReporter func(ctx context.Context, duty core.Duty, failed bool, participatedShares map[int]int, unexpectedPeers map[int]int, expectedPerPeer int) + + // builderAuthReporter instruments the builder request auths of each peer by builder. + builderAuthReporter func(duty core.Duty, events []event) +} + +// Option configures a Tracker. +type Option func(*options) + +// options are the optional Tracker settings. +type options struct { + builderHosts []string +} + +// WithBuilderHosts sets the hosts of the builders configured on this node, by which the builder +// request auths of each peer are counted. +func WithBuilderHosts(hosts []string) Option { + return func(o *options) { + o.builderHosts = hosts + } } // New returns a new Tracker. The deleter deadliner must return well after analyser deadliner since duties of the same slot are often analysed together. -func New(analyser core.Deadliner, deleter core.Deadliner, peers []p2p.Peer, fromSlot uint64) *Tracker { +func New(analyser core.Deadliner, deleter core.Deadliner, peers []p2p.Peer, fromSlot uint64, opts ...Option) *Tracker { + var o options + for _, opt := range opts { + opt(&o) + } + t := &Tracker{ input: make(chan event), events: make(map[core.Duty][]event), @@ -126,6 +150,7 @@ func New(analyser core.Deadliner, deleter core.Deadliner, peers []p2p.Peer, from parSigReporter: reportParSigs, failedDutyReporter: newFailedDutyReporter(), participationReporter: newParticipationReporter(peers), + builderAuthReporter: newBuilderAuthReporter(peers, o.builderHosts), } return t @@ -178,6 +203,8 @@ func (t *Tracker) Run(ctx context.Context) error { // Analyse peer participation participatedShares, unexpectedShares, expectedPerPeer := analyseParticipation(duty, t.events) t.participationReporter(ctx, duty, failed, participatedShares, unexpectedShares, expectedPerPeer) + + t.builderAuthReporter(duty, t.events[duty]) case duty := <-t.deleter.C(): delete(t.events, duty) } @@ -299,7 +326,9 @@ func analyseDutyFailed(duty core.Duty, allEvents map[core.Duty][]event, msgRootC return true, parSigDBExternal, reasonBugParSigDBExternal, failedErr } - if msgRootConsistent { + // A proposer authorizes several builders for the same slot, so builder request auths + // legitimately carry different message roots for the same duty and pubkey. + if msgRootConsistent || duty.Type == core.DutyBuilderPreferences { reason = reasonInsufficientPeerSignatures } else { reason = reasonBugParSigDBInconsistent @@ -663,11 +692,81 @@ func analyseParticipation(duty core.Duty, allEvents map[core.Duty][]event) (resp return resp, unexpectedShares, pubkeyMapLen } +// builderUnknown is the builder label of builder request auths for builders not configured on this node. +const builderUnknown = "unknown" + +// newBuilderAuthReporter returns a reporter counting the partially signed builder request auths of +// each peer by builder host, so that a peer whose validator client doesn't sign for a configured +// builder, or still signs for a removed one, stands out. Hosts of builders not configured on this +// node are counted as unknown, bounding the metric's cardinality. +func newBuilderAuthReporter(peers []p2p.Peer, builderHosts []string) func(core.Duty, []event) { + configured := make(map[string]bool) + for _, host := range builderHosts { + configured[host] = true + } + + peerNames := make(map[int]string) + for _, peer := range peers { + peerNames[peer.ShareIdx()] = peer.Name + + // Initialise counters to 0 to avoid non-existent metrics issues when querying prometheus. + for _, host := range builderHosts { + builderRequestAuths.WithLabelValues(peer.Name, host).Add(0) + } + } + + return func(duty core.Duty, events []event) { + if duty.Type != core.DutyBuilderPreferences { + return + } + + // A peer's auth for a validator and builder is counted once, whichever step reported it. + type dedupKey struct { + pubkey core.PubKey + shareIdx int + builder string + } + + dedup := make(map[dedupKey]bool) + + for _, e := range events { + if (e.step != parSigDBInternal && e.step != parSigDBExternal) || e.parSig == nil { + continue + } + + auth, ok := e.parSig.SignedData.(core.SignedBuilderRequestAuth) + if !ok || auth.Message == nil { + continue + } + + builder := string(auth.Message.Data) + if !configured[builder] { + builder = builderUnknown + } + + key := dedupKey{pubkey: e.pubkey, shareIdx: e.parSig.ShareIdx, builder: builder} + if dedup[key] { + continue + } + + dedup[key] = true + + name, ok := peerNames[e.parSig.ShareIdx] + if !ok { + continue + } + + builderRequestAuths.WithLabelValues(name, builder).Inc() + } + } +} + // isParSigEventExpected returns true if a partial signature event is expected for the given duty and pubkey. // It basically checks if the duty (or an associated duty) was scheduled. func isParSigEventExpected(duty core.Duty, pubkey core.PubKey, allEvents map[core.Duty][]event) bool { // Cannot validate validatorAPI triggered duties that are not linked to locally scheduled duties. - if duty.Type == core.DutyExit || duty.Type == core.DutyBuilderRegistration || duty.Type == core.DutyProposerPreferences { + if duty.Type == core.DutyExit || duty.Type == core.DutyBuilderRegistration || duty.Type == core.DutyProposerPreferences || + duty.Type == core.DutyBuilderPreferences { return true } @@ -934,6 +1033,13 @@ func reportParSigs(ctx context.Context, duty core.Duty, parsigMsgs parsigsByMsg) return } + // A proposer authorizes several builders for the same slot with distinct request auths, and + // which builder's auth a peer is kept with depends on the event order, so differing message + // roots are expected (see analyseDutyFailed). + if duty.Type == core.DutyBuilderPreferences { + return + } + if parsigMsgs.MsgRootsConsistent() { return // Nothing to report. } diff --git a/core/tracker/tracker_internal_test.go b/core/tracker/tracker_internal_test.go index dfc1e6387..981d9c385 100644 --- a/core/tracker/tracker_internal_test.go +++ b/core/tracker/tracker_internal_test.go @@ -15,6 +15,7 @@ import ( eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2spec "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/gloas" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" promtestutil "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -347,6 +348,16 @@ func TestAnalyseDutyFailed(t *testing.T) { require.Equal(t, step, parSigDBExternal) require.Equal(t, reason, reasonParSigDBInconsistentPreferences) + // A proposer authorizes several builders for the same slot, so builder request auths + // with different message roots are expected: missing threshold is insufficient signatures. + builderPrefDuty := core.NewBuilderPreferencesDuty(uint64(slot)) + events[builderPrefDuty] = events[attDuty] + failed, step, reason, err = analyseDutyFailed(builderPrefDuty, events, false) + require.NoError(t, err) + require.True(t, failed) + require.Equal(t, step, parSigDBExternal) + require.Equal(t, reason, reasonInsufficientPeerSignatures) + // Execution payload envelopes are consensus-gated, so inconsistent partial signatures are a // bug (like a block proposal), not the expected divergence of proposer preferences. envelopeDuty := core.NewExecutionPayloadEnvelopeDuty(uint64(slot)) @@ -1205,6 +1216,11 @@ func TestIsParSigEventExpected(t *testing.T) { duty: core.NewProposerPreferencesDuty(slot), out: true, }, + { + name: "DutyBuilderPreferences", + duty: core.NewBuilderPreferencesDuty(slot), + out: true, + }, { name: "DutyRandao expected", duty: core.NewRandaoDuty(slot), @@ -1732,3 +1748,90 @@ func TestReportSyncMessageCohorts(t *testing.T) { require.InDelta(t, before, promtestutil.ToFloat64(cohortRank.WithLabelValues(attLabel, "0", "0")), 0) }) } + +// TestReportParSigsBuilderPreferences verifies that builder request auths with differing message +// roots aren't reported as inconsistent: a proposer authorizes each builder with its own auth, and +// which builder's auth is kept per peer depends on the order the events arrived in. +func TestReportParSigsBuilderPreferences(t *testing.T) { + duty := core.NewBuilderPreferencesDuty(123) + + inconsistentVal := func() float64 { + return promtestutil.ToFloat64(inconsistentCounter.WithLabelValues(duty.Type.String())) + } + + before := inconsistentVal() + + // Shares 1 and 2 are kept with builder A's auth, share 3 with builder B's. + reportParSigs(context.Background(), duty, parsigsByMsg{"pk": {rootFrom(0x0a): syncSigs(1, 2), rootFrom(0x0b): syncSigs(3)}}) + + require.InDelta(t, before, inconsistentVal(), 0) +} + +func TestBuilderAuthReporter(t *testing.T) { + const ( + builderA = "builder-a.example.com" + builderB = "builder-b.example.com" + builderC = "builder-c.example.com" + removed = "removed.example.com" + ) + + // This node has builders A, B and C configured. + peers := []p2p.Peer{{Index: 0, Name: "peer1"}, {Index: 1, Name: "peer2"}, {Index: 2, Name: "peer3"}} + reporter := newBuilderAuthReporter(peers, []string{builderA, builderB, builderC}) + + duty := core.NewBuilderPreferencesDuty(1) + pubkey := testutil.RandomCorePubKey(t) + + authEvent := func(step step, shareIdx int, host string) event { + parSig := core.NewPartialSignedBuilderRequestAuth(&gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte(host), Slot: 1}, + }, shareIdx) + + return event{duty: duty, step: step, pubkey: pubkey, parSig: &parSig} + } + + count := func(peer, builder string) float64 { + return promtestutil.ToFloat64(builderRequestAuths.WithLabelValues(peer, builder)) + } + + before := make(map[string]float64) + + for _, peer := range []string{"peer1", "peer2", "peer3"} { + for _, builder := range []string{builderA, builderB, builderC, builderUnknown} { + before[peer+builder] = count(peer, builder) + } + } + + reporter(duty, []event{ + // Peer 1's validator client signs for all three builders. + authEvent(parSigDBInternal, 1, builderA), + authEvent(parSigDBInternal, 1, builderB), + authEvent(parSigDBInternal, 1, builderC), + // Peer 2's validator client doesn't know builder C. A repeated auth is counted once. + authEvent(parSigDBExternal, 2, builderA), + authEvent(parSigDBExternal, 2, builderA), + authEvent(parSigDBExternal, 2, builderB), + // Peer 3's validator client signs for A and C, and still for a removed builder. Only + // stored auths are counted. + authEvent(parSigEx, 3, builderB), + authEvent(parSigDBExternal, 3, builderA), + authEvent(parSigDBExternal, 3, builderC), + authEvent(parSigDBExternal, 3, removed), + }) + + expected := map[string]float64{ + "peer1" + builderA: 1, "peer1" + builderB: 1, "peer1" + builderC: 1, + "peer2" + builderA: 1, "peer2" + builderB: 1, + "peer3" + builderA: 1, "peer3" + builderC: 1, "peer3" + builderUnknown: 1, + } + + for _, peer := range []string{"peer1", "peer2", "peer3"} { + for _, builder := range []string{builderA, builderB, builderC, builderUnknown} { + require.InDelta(t, before[peer+builder]+expected[peer+builder], count(peer, builder), 0, "%s %s", peer, builder) + } + } + + // Other duties are ignored. + reporter(core.NewProposerDuty(1), []event{authEvent(parSigDBInternal, 1, builderA)}) + require.InDelta(t, before["peer1"+builderA]+1, count("peer1", builderA), 0) +} diff --git a/core/types.go b/core/types.go index 3d672c595..c82cbc861 100644 --- a/core/types.go +++ b/core/types.go @@ -47,9 +47,10 @@ const ( DutyPayloadAttestation DutyType = 14 DutyProposerPreferences DutyType = 15 DutyExecutionPayloadEnvelope DutyType = 16 + DutyBuilderPreferences DutyType = 17 // Only ever append new types here... - dutySentinel DutyType = 17 // Must always be last + dutySentinel DutyType = 18 // Must always be last ) func (d DutyType) Valid() bool { @@ -75,6 +76,7 @@ func (d DutyType) String() string { DutyPayloadAttestation: "payload_attestation", DutyProposerPreferences: "proposer_preferences", DutyExecutionPayloadEnvelope: "execution_payload_envelope", + DutyBuilderPreferences: "builder_preferences", }[d] } @@ -271,6 +273,15 @@ func NewProposerPreferencesDuty(slot uint64) Duty { } } +// NewBuilderPreferencesDuty returns a new builder preferences duty, keyed by the proposal slot +// the builder request authorizations are for. +func NewBuilderPreferencesDuty(slot uint64) Duty { + return Duty{ + Slot: slot, + Type: DutyBuilderPreferences, + } +} + // NewExecutionPayloadEnvelopeDuty returns a new execution payload envelope duty, keyed by the // proposal slot. It is a convenience function that is slightly more readable and concise than the // struct literal equivalent. diff --git a/core/types_test.go b/core/types_test.go index b0ee30d92..61d753adc 100644 --- a/core/types_test.go +++ b/core/types_test.go @@ -35,9 +35,10 @@ func TestBackwardsCompatibility(t *testing.T) { require.EqualValues(t, 14, core.DutyPayloadAttestation) require.EqualValues(t, 15, core.DutyProposerPreferences) require.EqualValues(t, 16, core.DutyExecutionPayloadEnvelope) + require.EqualValues(t, 17, core.DutyBuilderPreferences) // Add more types here. - const sentinel = core.DutyType(17) + const sentinel = core.DutyType(18) for i := core.DutyUnknown; i <= sentinel; i++ { switch i { case core.DutyUnknown: @@ -81,7 +82,7 @@ func TestWithDutySpanCtx(t *testing.T) { func TestAllDutyTypes(t *testing.T) { adt := core.AllDutyTypes() - require.Len(t, adt, 16) + require.Len(t, adt, 17) for i, dt := range adt { require.Equal(t, i, slices.Index(adt, dt)) diff --git a/core/validatorapi/metrics.go b/core/validatorapi/metrics.go index b2d1be432..672eb7ff5 100644 --- a/core/validatorapi/metrics.go +++ b/core/validatorapi/metrics.go @@ -63,6 +63,14 @@ var ( Name: "proposer_preferences_mismatch_total", Help: "Total number of proposer preferences submitted by the VC that mismatch the cluster-lock value, by field", }, []string{"field"}) + + builderPrefMismatch = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "core", + Subsystem: "validatorapi", + Name: "builder_preferences_mismatch_total", + Help: "Total number of builder preferences submitted by the VC that mismatch this node's builders, by builder host " + + "and reason: a configured builder without a request auth (missing) or an auth for a builder not configured (unknown)", + }, []string{"builder", "reason"}) ) // Values of proposalFormGauge, the form the latest gloas proposal was requested in. @@ -76,6 +84,25 @@ func incProposerPrefMismatch(field string) { proposerPrefMismatch.WithLabelValues(field).Inc() } +func incBuilderPrefMismatch(builder, reason string) { + builderPrefMismatch.WithLabelValues(builder, reason).Inc() +} + +// maxBuilderHostLabelLen bounds the untrusted builder request auth data used as a metric label +// value, at the maximum length of a hostname, the data's default. +const maxBuilderHostLabelLen = 253 + +// builderHostLabel returns the builder host of a request auth as a metric label value: capped and +// valid UTF-8 like the User-Agent header, since it is unverified validator client input and the +// prometheus client panics on an invalid label value. +func builderHostLabel(host string) string { + if len(host) > maxBuilderHostLabelLen { + host = host[:maxBuilderHostLabelLen] + } + + return strings.ToValidUTF8(host, "") +} + func incAPIErrors(endpoint string, statusCode int) { apiErrors.WithLabelValues(endpoint, strconv.Itoa(statusCode)).Inc() } diff --git a/core/validatorapi/mocks/handler.go b/core/validatorapi/mocks/handler.go index 028bbe44b..e29635b13 100644 --- a/core/validatorapi/mocks/handler.go +++ b/core/validatorapi/mocks/handler.go @@ -10,8 +10,6 @@ import ( context "context" - gloas "github.com/attestantio/go-eth2-client/spec/gloas" - http "net/http" mock "github.com/stretchr/testify/mock" @@ -510,6 +508,24 @@ func (_m *Handler) SubmitBlindedProposal(ctx context.Context, opts *api.SubmitBl return r0 } +// SubmitBuilderPreferences provides a mock function with given fields: ctx, opts +func (_m *Handler) SubmitBuilderPreferences(ctx context.Context, opts *api.SubmitBuilderPreferencesOpts) error { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for SubmitBuilderPreferences") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitBuilderPreferencesOpts) error); ok { + r0 = rf(ctx, opts) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SubmitExecutionPayloadEnvelope provides a mock function with given fields: ctx, opts func (_m *Handler) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *api.SubmitExecutionPayloadEnvelopeOpts) error { ret := _m.Called(ctx, opts) @@ -564,17 +580,17 @@ func (_m *Handler) SubmitProposal(ctx context.Context, opts *api.SubmitProposalO return r0 } -// SubmitProposerPreferences provides a mock function with given fields: ctx, preferences -func (_m *Handler) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { - ret := _m.Called(ctx, preferences) +// SubmitProposerPreferences provides a mock function with given fields: ctx, opts +func (_m *Handler) SubmitProposerPreferences(ctx context.Context, opts *api.SubmitProposerPreferencesOpts) error { + ret := _m.Called(ctx, opts) if len(ret) == 0 { panic("no return value specified for SubmitProposerPreferences") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, []*gloas.SignedProposerPreferences) error); ok { - r0 = rf(ctx, preferences) + if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitProposerPreferencesOpts) error); ok { + r0 = rf(ctx, opts) } else { r0 = ret.Error(0) } diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go index 23072a521..a26ddaa6a 100644 --- a/core/validatorapi/router.go +++ b/core/validatorapi/router.go @@ -93,6 +93,7 @@ type Handler interface { eth2client.ProposerDutiesProvider eth2client.ProposerDutiesV2Provider eth2client.ProposerPreferencesSubmitter + eth2client.BuilderPreferencesSubmitter eth2client.PTCDutiesProvider eth2client.SyncCommitteeContributionProvider eth2client.SyncCommitteeContributionsSubmitter @@ -387,6 +388,14 @@ func NewRouter(h Handler, builderEnabled bool) (*mux.Router, error) { Encodings: []contentType{contentTypeJSON, contentTypeSSZ}, MaxBody: maxProposerPreferencesBody, }, + { + Name: "submit_builder_preferences", + Path: "/eth/v1/validator/builder_preferences", + Handler: submitBuilderPreferences(h), + Methods: []string{http.MethodPost}, + Encodings: []contentType{contentTypeJSON, contentTypeSSZ}, + MaxBody: maxBuilderPreferencesBody, + }, { Name: "aggregate_sync_committee_selections", Path: "/eth/v1/validator/sync_committee_selections", @@ -1207,12 +1216,12 @@ func proposeBlockV3(p eth2client.ProposalProvider, builderEnabled bool) handlerF const maxBuilderConfigBody = 1 << 20 // 1MB // proposeBlockV4 returns a handler function returning an unsigned gloas EPBS proposal. -// The VC-supplied builder config body is not forwarded: the cluster proposal was already -// fetched with charon's own builder configuration. -// TODO(gloas): substitute threshold-aggregated builder request auths into the forwarded -// builder config once the builder preferences duty lands. +// The VC-supplied builder config body is not forwarded as such: the cluster proposal is fetched +// with charon's own builder configuration. Only the builder request auths a validator client +// sends inline with it, instead of ahead of time as builder preferences, are submitted for +// threshold aggregation. func proposeBlockV4(p eth2client.EPBSProposalProvider) handlerFunc { - return func(ctx context.Context, params map[string]string, _ http.Header, query url.Values, _ contentType, _ []byte) (any, http.Header, error) { + return func(ctx context.Context, params map[string]string, _ http.Header, query url.Values, typ contentType, body []byte) (any, http.Header, error) { slot, randao, graffiti, err := getProposeBlockParams(params, query) if err != nil { return nil, nil, err @@ -1233,6 +1242,19 @@ func proposeBlockV4(p eth2client.EPBSProposalProvider) handlerFunc { IncludePayload: &includePayload, } + // The inline builder request auths are optional, so a body the builder config can't be + // decoded from doesn't fail the request. + if len(body) > 0 { + var config gloas.BuilderConfig + + err := unmarshal(typ, body, &config) + if err != nil { + log.Warn(ctx, "Ignoring undecodable builder config of block production request", err) + } else { + opts.BuilderConfig = &config + } + } + eth2Resp, err := p.EPBSProposal(ctx, opts) if err != nil { return nil, nil, err @@ -2147,7 +2169,50 @@ func submitProposerPreferences(h Handler) handlerFunc { return nil, nil, errors.Wrap(err, "unmarshal proposer preferences") } - err = h.SubmitProposerPreferences(ctx, prefs) + err = h.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: prefs}) + if err != nil { + return nil, nil, err + } + + return nil, nil, nil + } +} + +// maxBuilderPreferencesBody bounds the builder preferences request body. The spec list limit +// (MAX_BUILDER_ENTRIES per slot of the proposer lookahead) far exceeds what a validator client +// submits for the few proposals of its validators within the lookahead. +const maxBuilderPreferencesBody = 2 << 20 // 2MB + +// submitBuilderPreferences receives the builder preferences of the validator client and forwards +// their partially signed builder request auths for threshold aggregation. +func submitBuilderPreferences(h Handler) handlerFunc { + return func(ctx context.Context, _ map[string]string, header http.Header, _ url.Values, typ contentType, body []byte) (any, http.Header, error) { + var version eth2spec.DataVersion + + err := version.UnmarshalJSON([]byte("\"" + header.Get(versionHeader) + "\"")) + if err != nil { + return nil, nil, apiError{ + StatusCode: http.StatusBadRequest, + Message: "invalid or missing " + versionHeader + " header", + Err: err, + } + } + + if version != eth2spec.DataVersionGloas { + return nil, nil, apiError{ + StatusCode: http.StatusBadRequest, + Message: "unsupported " + versionHeader + " header, expected gloas", + } + } + + var entries eth2v1gloas.BuilderPreferencesEntryList + + err = unmarshal(typ, body, &entries) + if err != nil { + return nil, nil, errors.Wrap(err, "unmarshal builder preferences") + } + + err = h.SubmitBuilderPreferences(ctx, ð2api.SubmitBuilderPreferencesOpts{Preferences: entries}) if err != nil { return nil, nil, err } diff --git a/core/validatorapi/router_internal_test.go b/core/validatorapi/router_internal_test.go index 79a084aa0..2cde2412e 100644 --- a/core/validatorapi/router_internal_test.go +++ b/core/validatorapi/router_internal_test.go @@ -2576,6 +2576,7 @@ type testHandler struct { PTCDutiesFunc func(ctx context.Context, opts *eth2api.PTCDutiesOpts) (*eth2api.Response[[]*eth2v1.PTCDuty], error) SubmitPayloadAttMsgsFunc func(ctx context.Context, opts *eth2api.SubmitPayloadAttestationMessagesOpts) error SubmitProposerPreferencesFunc func(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error + SubmitBuilderPreferencesFunc func(ctx context.Context, preferences []*gloas.BuilderPreferencesEntry) error SubmitExecutionPayloadEnvelopeFunc func(ctx context.Context, opts *eth2api.SubmitExecutionPayloadEnvelopeOpts) error ProxyFunc func(ctx context.Context, req *http.Request) (*http.Response, error) AddressFunc func() string @@ -2598,8 +2599,12 @@ func (h testHandler) SubmitPayloadAttestationMessages(ctx context.Context, opts return h.SubmitPayloadAttMsgsFunc(ctx, opts) } -func (h testHandler) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { - return h.SubmitProposerPreferencesFunc(ctx, preferences) +func (h testHandler) SubmitProposerPreferences(ctx context.Context, opts *eth2api.SubmitProposerPreferencesOpts) error { + return h.SubmitProposerPreferencesFunc(ctx, opts.Preferences) +} + +func (h testHandler) SubmitBuilderPreferences(ctx context.Context, opts *eth2api.SubmitBuilderPreferencesOpts) error { + return h.SubmitBuilderPreferencesFunc(ctx, opts.Preferences) } func (h testHandler) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *eth2api.SubmitExecutionPayloadEnvelopeOpts) error { @@ -3705,3 +3710,170 @@ func TestSubmitExecutionPayloadEnvelopeRouter(t *testing.T) { }) }) } + +func TestSubmitBuilderPreferencesRouter(t *testing.T) { + newEntry := func(data string) *gloas.BuilderPreferencesEntry { + return &gloas.BuilderPreferencesEntry{ + ProposerPubkey: testutil.RandomEth2PubKey(t), + URL: []byte("https://" + data), + Auth: &gloas.SignedBuilderRequestAuth{Message: &gloas.BuilderRequestAuth{Data: []byte(data), Slot: 1}, Signature: testutil.RandomEth2Signature()}, + MaxExecutionPayment: 7, + } + } + + entries := []*gloas.BuilderPreferencesEntry{newEntry("relay-a.example.com"), newEntry("relay-b.example.com")} + + newHandler := func(submitted *[][]*gloas.BuilderPreferencesEntry) testHandler { + return testHandler{ + SubmitBuilderPreferencesFunc: func(_ context.Context, preferences []*gloas.BuilderPreferencesEntry) error { + *submitted = append(*submitted, preferences) + + return nil + }, + } + } + + post := func(ctx context.Context, t *testing.T, baseURL, contentType, version string, body []byte) *http.Response { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/eth/v1/validator/builder_preferences", bytes.NewReader(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", contentType) + + if version != "" { + req.Header.Set(versionHeader, version) + } + + res, err := new(http.Client).Do(req) + require.NoError(t, err) + + return res + } + + t.Run("json", func(t *testing.T) { + var submitted [][]*gloas.BuilderPreferencesEntry + + body, err := json.Marshal(entries) + require.NoError(t, err) + + testRawRouter(t, newHandler(&submitted), func(ctx context.Context, baseURL string) { + res := post(ctx, t, baseURL, "application/json", "gloas", body) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Len(t, submitted, 1) + require.Equal(t, entries, submitted[0]) + }) + }) + + t.Run("ssz", func(t *testing.T) { + var submitted [][]*gloas.BuilderPreferencesEntry + + list := apiv1gloas.BuilderPreferencesEntryList(entries) + body, err := list.MarshalSSZ() + require.NoError(t, err) + + testRawRouter(t, newHandler(&submitted), func(ctx context.Context, baseURL string) { + res := post(ctx, t, baseURL, "application/octet-stream", "gloas", body) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Len(t, submitted, 1) + require.Equal(t, entries, submitted[0]) + }) + }) + + t.Run("missing version header", func(t *testing.T) { + var submitted [][]*gloas.BuilderPreferencesEntry + + testRawRouter(t, newHandler(&submitted), func(ctx context.Context, baseURL string) { + res := post(ctx, t, baseURL, "application/json", "", []byte("[]")) + require.Equal(t, http.StatusBadRequest, res.StatusCode) + require.Empty(t, submitted) + }) + }) + + t.Run("wrong version header", func(t *testing.T) { + var submitted [][]*gloas.BuilderPreferencesEntry + + testRawRouter(t, newHandler(&submitted), func(ctx context.Context, baseURL string) { + res := post(ctx, t, baseURL, "application/json", "fulu", []byte("[]")) + require.Equal(t, http.StatusBadRequest, res.StatusCode) + require.Empty(t, submitted) + }) + }) +} + +func TestProposeBlockV4BuilderConfigBody(t *testing.T) { + proposal := ð2api.VersionedEPBSProposal{Version: eth2spec.DataVersionGloas, Gloas: testutil.RandomGloasBeaconBlock()} + + auth := &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte("relay-a.example.com"), Slot: 456}, + Signature: testutil.RandomEth2Signature(), + } + config := &gloas.BuilderConfig{ + MinBid: 3, + BuilderBoostFactor: 90, + Builders: []*gloas.BuilderEntry{{ + URL: []byte("https://relay-a.example.com"), + Auth: auth, + BuilderPubkeys: []eth2p0.BLSPubKey{}, + MaxExecutionPayment: 7, + }}, + } + + jsonBody, err := json.Marshal(config) + require.NoError(t, err) + + sszBody, err := config.MarshalSSZ() + require.NoError(t, err) + + tests := []struct { + name string + contentType string + body []byte + decoded bool + }{ + {name: "json", contentType: "application/json", body: jsonBody, decoded: true}, + {name: "ssz", contentType: "application/octet-stream", body: sszBody, decoded: true}, + // The inline builder request auths are optional, so a body the builder config can't + // be decoded from doesn't fail the request. + {name: "undecodable", contentType: "application/json", body: []byte("{}")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := testHandler{ + EPBSProposalFunc: func(_ context.Context, opts *eth2api.EPBSProposalOpts) (*eth2api.Response[*eth2api.VersionedEPBSProposal], error) { + require.Equal(t, eth2p0.Slot(456), opts.Slot) + + if !test.decoded { + require.Nil(t, opts.BuilderConfig) + + return wrapResponse(proposal), nil + } + + require.NotNil(t, opts.BuilderConfig) + require.EqualValues(t, 3, opts.BuilderConfig.MinBid) + require.EqualValues(t, 90, opts.BuilderConfig.BuilderBoostFactor) + require.Len(t, opts.BuilderConfig.Builders, 1) + require.Equal(t, []byte("https://relay-a.example.com"), opts.BuilderConfig.Builders[0].URL) + require.Equal(t, auth, opts.BuilderConfig.Builders[0].Auth) + require.EqualValues(t, 7, opts.BuilderConfig.Builders[0].MaxExecutionPayment) + + return wrapResponse(proposal), nil + }, + } + + testRawRouter(t, handler, func(ctx context.Context, baseURL string) { + url := fmt.Sprintf("%s/eth/v4/validator/blocks/456?randao_reveal=%#x&graffiti=%#x", baseURL, testutil.RandomEth2Signature(), testutil.RandomBytes32()) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(test.body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", test.contentType) + + res, err := new(http.Client).Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + }) + }) + } +} diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go index a17172152..592cdad59 100644 --- a/core/validatorapi/validatorapi.go +++ b/core/validatorapi/validatorapi.go @@ -93,7 +93,7 @@ func NewComponentInsecure(_ *testing.T, eth2Cl eth2wrap.Client, shareIdx int) (* // NewComponent returns a new instance of the validator API core workflow component. func NewComponent(eth2Cl eth2wrap.Client, allPubSharesByKey map[core.PubKey]map[int]tbls.PublicKey, - shareIdx int, feeRecipientFunc func(core.PubKey) string, builderEnabled bool, targetGasLimit uint, + shareIdx int, feeRecipientFunc func(core.PubKey) string, builderEnabled bool, targetGasLimit uint, opts ...Option, ) (*Component, error) { var ( sharesByKey = make(map[eth2p0.BLSPubKey]eth2p0.BLSPubKey) @@ -162,7 +162,7 @@ func NewComponent(eth2Cl eth2wrap.Client, allPubSharesByKey map[core.PubKey]map[ return key, nil } - return &Component{ + c := &Component{ getVerifyShareFunc: getVerifyShareFunc, getPubShareFunc: getPubShareFunc, getPubKeyFunc: getPubKeyFunc, @@ -173,7 +173,24 @@ func NewComponent(eth2Cl eth2wrap.Client, allPubSharesByKey map[core.PubKey]map[ builderEnabled: builderEnabled, targetGasLimit: targetGasLimit, swallowRegFilter: log.Filter(), - }, nil + } + + for _, opt := range opts { + opt(c) + } + + return c, nil +} + +// Option configures a Component. +type Option func(*Component) + +// WithBuilderHosts sets the hosts of the builders configured on this node, which the builder +// request auths the validator client submits are compared with. +func WithBuilderHosts(hosts []string) Option { + return func(c *Component) { + c.builderHosts = hosts + } } type Component struct { @@ -183,6 +200,7 @@ type Component struct { feeRecipientFunc func(core.PubKey) string builderEnabled bool targetGasLimit uint + builderHosts []string swallowRegFilter z.Field // getVerifyShareFunc maps public shares (what the VC thinks as its public key) @@ -203,7 +221,7 @@ type Component struct { awaitPayloadContentsFunc func(ctx context.Context, slot uint64, root eth2p0.Root) (*eth2api.VersionedEPBSProposal, error) awaitSyncContributionFunc func(ctx context.Context, slot, subcommIdx uint64, beaconBlockRoot eth2p0.Root) (*altair.SyncCommitteeContribution, error) awaitAggAttFunc func(ctx context.Context, slot uint64, attestationRoot eth2p0.Root, committeeIndex eth2p0.CommitteeIndex) (*eth2spec.VersionedAttestation, error) - awaitAggSigDBFunc func(context.Context, core.Duty, core.PubKey, core.SubcommitteeIndex) (core.SignedData, error) + awaitAggSigDBFunc func(context.Context, core.Duty, core.PubKey, core.AwaitOpts) (core.SignedData, error) dutyDefFunc func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) subs []func(context.Context, core.Duty, core.ParSignedDataSet) error } @@ -289,7 +307,7 @@ func (c *Component) RegisterAwaitAggAttestation(fn func(ctx context.Context, slo } // RegisterAwaitAggSigDB registers a function to query aggregated signed data from aggSigDB. -func (c *Component) RegisterAwaitAggSigDB(fn func(context.Context, core.Duty, core.PubKey, core.SubcommitteeIndex) (core.SignedData, error)) { +func (c *Component) RegisterAwaitAggSigDB(fn func(context.Context, core.Duty, core.PubKey, core.AwaitOpts) (core.SignedData, error)) { c.awaitAggSigDBFunc = fn } @@ -505,6 +523,14 @@ func (c Component) EPBSProposal(ctx context.Context, opts *eth2api.EPBSProposalO return nil, err } + // The inline builder request auths are optional, so failing to forward them doesn't fail the proposal. + if opts.BuilderConfig != nil { + err := c.submitInlineBuilderRequestAuths(ctx, opts.Slot, opts.BuilderConfig) + if err != nil { + log.Warn(ctx, "Inline builder request auths not forwarded for aggregation", err, z.U64("slot", uint64(opts.Slot))) + } + } + // Query unsigned proposal (this is blocking). Errors for pre-gloas proposals, which // are served by the v3 endpoint. proposal, err := c.awaitEPBSProposal(ctx, uint64(opts.Slot)) @@ -1172,7 +1198,7 @@ func (c Component) BeaconCommitteeSelections(ctx context.Context, opts *eth2api. duty := core.NewPrepareAggregatorDuty(uint64(slot)) for pk := range data { // Query aggregated subscription from aggsigdb for each duty and public key (this is blocking). - s, err := c.awaitAggSigDBFunc(ctx, duty, pk, 0) + s, err := c.awaitAggSigDBFunc(ctx, duty, pk, core.AwaitOpts{}) if err != nil { return nil, err } @@ -1358,7 +1384,13 @@ func (c Component) SubmitSyncCommitteeMessages(ctx context.Context, messages []* // proposal slot for threshold aggregation. Preferences are aggregated ungated (like sync committee // messages): the call never waits for other shares; the submission completing the threshold // synchronously triggers aggregation, like other VC-pushed duties. -func (c Component) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { +func (c Component) SubmitProposerPreferences(ctx context.Context, opts *eth2api.SubmitProposerPreferencesOpts) error { + if opts == nil { + return badRequestError("no proposer preferences options", nil) + } + + preferences := opts.Preferences + // Use complete validators since preferences are submitted ahead of time: a validator // activating in the proposal epoch is a valid proposer but not yet active when submitting. vals, err := c.eth2Cl.CompleteValidators(ctx) @@ -1459,6 +1491,203 @@ func (c Component) SubmitProposerPreferences(ctx context.Context, preferences [] return nil } +// SubmitBuilderPreferences receives the builder preferences of the validator client, verifies the +// partially signed builder request auth of each entry and forwards them to subscribers for +// threshold aggregation, grouped by proposal slot and builder since a proposer authorizes each +// builder separately. Only the auth is aggregated: the builder URL and maximum execution payment +// come from charon's own builder configuration, which is identical across the cluster. +func (c Component) SubmitBuilderPreferences(ctx context.Context, opts *eth2api.SubmitBuilderPreferencesOpts) error { + if opts == nil { + return badRequestError("no builder preferences options", nil) + } + + currentSlot, err := SlotFromTimestamp(ctx, c.eth2Cl, time.Now()) + if err != nil { + return err + } + + _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, c.eth2Cl) + if err != nil { + return err + } + + const ( + // MIN_SEED_LOOKAHEAD as per the consensus spec phase0 preset (identical on all networks). + minSeedLookahead = 1 + // MAX_BUILDER_ENTRIES as per the beacon-APIs gloas builder preferences. + maxBuilderEntries = 64 + ) + + // The request body is defined as + // List[BuilderPreferencesEntry, MAX_BUILDER_ENTRIES * (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH]. + // Enforce the list limit before any signature verification to bound memory and CPU. + limit := maxBuilderEntries * (minSeedLookahead + 1) * slotsPerEpoch + if uint64(len(opts.Preferences)) > limit { + return badRequestError("too many builder preferences", + errors.New("too many builder preferences", z.Int("count", len(opts.Preferences)), z.U64("limit", limit))) + } + + // Auths are only valid for future proposal slots within the proposer lookahead. + maxSlot := eth2p0.Slot((uint64(currentSlot)/slotsPerEpoch + 1 + minSeedLookahead) * slotsPerEpoch) + + type groupKey struct { + slot eth2p0.Slot + pubkey core.PubKey + } + + var ( + groups []groupKey + authsByGroup = make(map[groupKey][]*gloas.SignedBuilderRequestAuth) + ) + + // Faulty entries are skipped with a warning instead of failing the whole batch: entries + // target independent proposal slots and builders, and the list limit bounds the work. + for _, entry := range opts.Preferences { + if entry == nil || entry.Auth == nil || entry.Auth.Message == nil { + log.Warn(ctx, "Skipping builder preferences without request auth", nil) + continue + } + + slot := entry.Auth.Message.Slot + if slot <= currentSlot || slot >= maxSlot { + log.Warn(ctx, "Skipping builder preferences with proposal slot outside lookahead window", nil, + z.U64("proposal_slot", uint64(slot)), z.U64("current_slot", uint64(currentSlot)), z.U64("max_slot", uint64(maxSlot))) + + continue + } + + pubkey, err := c.getPubKeyFunc(entry.ProposerPubkey) + if err != nil { + log.Warn(ctx, "Skipping builder preferences for unknown public share", err) + continue + } + + pk, err := core.PubKeyFromBytes(pubkey[:]) + if err != nil { + return err + } + + key := groupKey{slot: slot, pubkey: pk} + if _, ok := authsByGroup[key]; !ok { + groups = append(groups, key) + } + + authsByGroup[key] = append(authsByGroup[key], entry.Auth) + } + + for _, key := range groups { + err = c.forwardBuilderRequestAuths(ctx, key.slot, key.pubkey, authsByGroup[key]) + if err != nil { + return err + } + } + + return nil +} + +// submitInlineBuilderRequestAuths forwards the builder request auths a validator client sends +// inline with its block production request, instead of ahead of time as builder preferences. +// Builders without an auth have nothing to aggregate, and an auth for another slot is skipped. +func (c Component) submitInlineBuilderRequestAuths(ctx context.Context, slot eth2p0.Slot, config *gloas.BuilderConfig) error { + var auths []*gloas.SignedBuilderRequestAuth + + for _, entry := range config.Builders { + if entry == nil || entry.Auth == nil || entry.Auth.Message == nil { + continue + } + + if entry.Auth.Message.Slot != slot { + log.Warn(ctx, "Skipping inline builder request auth for another slot", nil, + z.U64("proposal_slot", uint64(slot)), z.U64("auth_slot", uint64(entry.Auth.Message.Slot))) + + continue + } + + auths = append(auths, entry.Auth) + } + + if len(auths) == 0 { + return nil + } + + pubkey, err := c.getProposerPubkey(ctx, core.NewProposerDuty(uint64(slot))) + if err != nil { + return err + } + + return c.forwardBuilderRequestAuths(ctx, slot, pubkey, auths) +} + +// forwardBuilderRequestAuths verifies the validator's partially signed builder request auths for +// the proposal slot and forwards each to the subscribers for threshold aggregation, since a +// proposer authorizes each builder separately. An invalid auth is skipped with a warning. +func (c Component) forwardBuilderRequestAuths(ctx context.Context, slot eth2p0.Slot, pubkey core.PubKey, auths []*gloas.SignedBuilderRequestAuth) error { + duty := core.NewBuilderPreferencesDuty(uint64(slot)) + + c.warnBuilderPreferencesMismatch(ctx, slot, pubkey, auths) + + for _, auth := range auths { + parSigData := core.NewPartialSignedBuilderRequestAuth(auth, c.shareIdx) + + err := c.verifyPartialSig(ctx, parSigData, pubkey) + if err != nil { + log.Warn(ctx, "Skipping builder request auth with invalid partial signature", err, + z.U64("proposal_slot", uint64(slot)), z.Any("pubkey", pubkey), z.Str("auth_data", string(auth.Message.Data))) + + continue + } + + log.Debug(ctx, "Builder request auth received from validator client", + z.U64("proposal_slot", uint64(slot)), z.Str("auth_data", string(auth.Message.Data))) + + for _, sub := range c.subs { + err := sub(ctx, duty, core.ParSignedDataSet{pubkey: parSigData}) + if err != nil { + return err + } + } + } + + return nil +} + +// warnBuilderPreferencesMismatch logs a warning and increments a metric for each builder configured +// on this node that the validator client submitted no request auth for, and for each auth for a +// builder not configured on this node, e.g. of a stale validator client configuration. It compares +// the auths of a proposal slot and validator submitted together, and doesn't reject any: an auth +// only aggregates with the matching auths of the other nodes. +func (c Component) warnBuilderPreferencesMismatch(ctx context.Context, slot eth2p0.Slot, pubkey core.PubKey, auths []*gloas.SignedBuilderRequestAuth) { + submitted := make(map[string]bool) + for _, auth := range auths { + submitted[string(auth.Message.Data)] = true + } + + configured := make(map[string]bool) + + for _, host := range c.builderHosts { + configured[host] = true + + if !submitted[host] { + log.Warn(ctx, "Validator client submitted no builder request auth for a configured builder", nil, + z.U64("proposal_slot", uint64(slot)), z.Any("pubkey", pubkey), z.Str("builder", host)) + incBuilderPrefMismatch(host, "missing") + } + } + + for _, auth := range auths { + host := string(auth.Message.Data) + if configured[host] { + continue + } + + configured[host] = true // Report each unknown builder once. + + log.Warn(ctx, "Validator client submitted a builder request auth for a builder not configured on this node", nil, + z.U64("proposal_slot", uint64(slot)), z.Any("pubkey", pubkey), z.Str("builder", host)) + incBuilderPrefMismatch(builderHostLabel(host), "unknown") + } +} + // warnProposerPreferencesMismatch logs a warning and increments a metric when the VC-submitted fee // recipient or target gas limit differs from the cluster-lock value. It does not reject the // preference: aggregation proceeds on whatever value reaches threshold (so a staggered gas-limit @@ -1718,7 +1947,7 @@ func (c Component) SyncCommitteeSelections(ctx context.Context, opts *eth2api.Sy duty := core.NewPrepareSyncContributionDuty(uint64(selection.Slot)) subcommIdx := core.SubcommitteeIndex(selection.SubcommitteeIndex) - s, err := c.awaitAggSigDBFunc(ctx, duty, pubkeys[i], subcommIdx) + s, err := c.awaitAggSigDBFunc(ctx, duty, pubkeys[i], core.AwaitOpts{SubcommIdx: subcommIdx}) if err != nil { return nil, err } diff --git a/core/validatorapi/validatorapi_internal_test.go b/core/validatorapi/validatorapi_internal_test.go index ddf9b5a93..ca5a723f7 100644 --- a/core/validatorapi/validatorapi_internal_test.go +++ b/core/validatorapi/validatorapi_internal_test.go @@ -5,6 +5,7 @@ package validatorapi import ( "context" "fmt" + "strings" "testing" eth2api "github.com/attestantio/go-eth2-client/api" @@ -209,3 +210,53 @@ func TestEPBSProposalFormGauge(t *testing.T) { propose(nil) require.Equal(t, proposalFormStateful, form()) } + +func TestWarnBuilderPreferencesMismatch(t *testing.T) { + const ( + builderA = "builder-a.example.com" + builderB = "builder-b.example.com" + builderC = "builder-c.example.com" + removed = "removed.example.com" + ) + + ctx := t.Context() + pubkey := testutil.RandomCorePubKey(t) + c := Component{builderHosts: []string{builderA, builderB, builderC}} + + auths := func(hosts ...string) []*gloas.SignedBuilderRequestAuth { + var resp []*gloas.SignedBuilderRequestAuth + for _, host := range hosts { + resp = append(resp, &gloas.SignedBuilderRequestAuth{Message: &gloas.BuilderRequestAuth{Data: []byte(host), Slot: 1}}) + } + + return resp + } + + count := func(builder, reason string) float64 { + return promtestutil.ToFloat64(builderPrefMismatch.WithLabelValues(builder, reason)) + } + + missingC, unknownRemoved := count(builderC, "missing"), count(removed, "unknown") + + // All configured builders: no increment. + c.warnBuilderPreferencesMismatch(ctx, 1, pubkey, auths(builderA, builderB, builderC)) + require.InDelta(t, missingC, count(builderC, "missing"), 0) + + // A validator client that doesn't know builder C. + c.warnBuilderPreferencesMismatch(ctx, 1, pubkey, auths(builderA, builderB)) + require.InDelta(t, missingC+1, count(builderC, "missing"), 0) + + // A validator client still signing for a removed builder, counted once per submission. + c.warnBuilderPreferencesMismatch(ctx, 1, pubkey, auths(builderA, builderB, builderC, removed, removed)) + require.InDelta(t, missingC+1, count(builderC, "missing"), 0) + require.InDelta(t, unknownRemoved+1, count(removed, "unknown"), 0) + + // The host is unverified validator client input, so it is capped and made valid UTF-8 before + // labelling, like the User-Agent header: the Prometheus client panics on an invalid label value. + oversized := strings.Repeat("a", 300) + oversizedBefore := count(oversized[:253], "unknown") + + c.warnBuilderPreferencesMismatch(ctx, 1, pubkey, auths(builderA, builderB, builderC, removed+"\xff", oversized)) + require.InDelta(t, unknownRemoved+2, count(removed, "unknown"), 0) + require.InDelta(t, oversizedBefore+1, count(oversized[:253], "unknown"), 0) +} diff --git a/core/validatorapi/validatorapi_test.go b/core/validatorapi/validatorapi_test.go index fb3c0fe1e..41d6d75b4 100644 --- a/core/validatorapi/validatorapi_test.go +++ b/core/validatorapi/validatorapi_test.go @@ -2193,7 +2193,7 @@ func TestComponent_AggregateBeaconCommitteeSelections(t *testing.T) { }, } - vapi.RegisterAwaitAggSigDB(func(_ context.Context, duty core.Duty, pk core.PubKey, _ core.SubcommitteeIndex) (core.SignedData, error) { + vapi.RegisterAwaitAggSigDB(func(_ context.Context, duty core.Duty, pk core.PubKey, _ core.AwaitOpts) (core.SignedData, error) { require.Equal(t, core.NewPrepareAggregatorDuty(slot), duty) for _, val := range valSet { @@ -2394,22 +2394,22 @@ func TestComponent_SubmitProposerPreferences(t *testing.T) { return nil }) - require.NoError(t, vapi.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{pref})) + require.NoError(t, vapi.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: []*gloas.SignedProposerPreferences{pref}})) require.Equal(t, count, 1) // Faulty entries are skipped with a warning, not forwarded and not failing the batch: // already-started slots, far-future slots, unknown validators and nil entries. pref.Message.ProposalSlot = currentSlot - require.NoError(t, vapi.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{pref})) + require.NoError(t, vapi.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: []*gloas.SignedProposerPreferences{pref}})) pref.Message.ProposalSlot = currentSlot + 100_000 - require.NoError(t, vapi.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{pref})) + require.NoError(t, vapi.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: []*gloas.SignedProposerPreferences{pref}})) pref.Message.ProposalSlot = currentSlot + 1 pref.Message.ValidatorIndex = 99_999 - require.NoError(t, vapi.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{pref})) + require.NoError(t, vapi.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: []*gloas.SignedProposerPreferences{pref}})) - require.NoError(t, vapi.SubmitProposerPreferences(ctx, []*gloas.SignedProposerPreferences{nil})) + require.NoError(t, vapi.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: []*gloas.SignedProposerPreferences{nil}})) require.Equal(t, count, 1) // Subscriber not called for any skipped entry. @@ -2422,7 +2422,7 @@ func TestComponent_SubmitProposerPreferences(t *testing.T) { tooMany[i] = testutil.RandomProposerPreferences() } - require.ErrorContains(t, vapi.SubmitProposerPreferences(ctx, tooMany), "too many proposer preferences") + require.ErrorContains(t, vapi.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: tooMany}), "too many proposer preferences") } func TestComponent_SubmitSyncCommitteeContributions(t *testing.T) { @@ -2827,7 +2827,7 @@ func TestComponent_AggregateSyncCommitteeSelectionsVerify(t *testing.T) { vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, false, 30000000) require.NoError(t, err) - vapi.RegisterAwaitAggSigDB(func(ctx context.Context, duty core.Duty, pubkey core.PubKey, subcommIdx core.SubcommitteeIndex) (core.SignedData, error) { + vapi.RegisterAwaitAggSigDB(func(ctx context.Context, duty core.Duty, pubkey core.PubKey, awaitOpts core.AwaitOpts) (core.SignedData, error) { require.Equal(t, core.NewPrepareSyncContributionDuty(slot), duty) for _, val := range valSet { @@ -2841,7 +2841,7 @@ func TestComponent_AggregateSyncCommitteeSelectionsVerify(t *testing.T) { for _, selection := range selections { if selection.ValidatorIndex == val.Index { require.Equal(t, eth2p0.Slot(slot), selection.Slot) - require.EqualValues(t, selection.SubcommitteeIndex, subcommIdx) + require.EqualValues(t, selection.SubcommitteeIndex, awaitOpts.SubcommIdx) return core.NewSyncCommitteeSelection(selection), nil } @@ -2904,7 +2904,7 @@ func TestComponent_SyncCommitteeSelectionsExitedValidator(t *testing.T) { vapi, err := validatorapi.NewComponentInsecure(t, bmock, 0) require.NoError(t, err) - vapi.RegisterAwaitAggSigDB(func(context.Context, core.Duty, core.PubKey, core.SubcommitteeIndex) (core.SignedData, error) { + vapi.RegisterAwaitAggSigDB(func(context.Context, core.Duty, core.PubKey, core.AwaitOpts) (core.SignedData, error) { return core.NewSyncCommitteeSelection(sel), nil }) @@ -2969,12 +2969,12 @@ func TestComponent_SyncCommitteeSelectionsMultiSubcommittee(t *testing.T) { vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, false, 30000000) require.NoError(t, err) - vapi.RegisterAwaitAggSigDB(func(_ context.Context, duty core.Duty, gotPk core.PubKey, subcommIdx core.SubcommitteeIndex) (core.SignedData, error) { + vapi.RegisterAwaitAggSigDB(func(_ context.Context, duty core.Duty, gotPk core.PubKey, awaitOpts core.AwaitOpts) (core.SignedData, error) { require.Equal(t, core.NewPrepareSyncContributionDuty(slot), duty) require.Equal(t, pk, gotPk) for _, sel := range selections { - if sel.SubcommitteeIndex == uint64(subcommIdx) { + if sel.SubcommitteeIndex == uint64(awaitOpts.SubcommIdx) { return core.NewSyncCommitteeSelection(sel), nil } } @@ -3064,9 +3064,9 @@ func TestComponent_SyncCommitteeSelectionsResponseOrder(t *testing.T) { vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, false, 30000000) require.NoError(t, err) - vapi.RegisterAwaitAggSigDB(func(_ context.Context, duty core.Duty, gotPk core.PubKey, subcommIdx core.SubcommitteeIndex) (core.SignedData, error) { + vapi.RegisterAwaitAggSigDB(func(_ context.Context, duty core.Duty, gotPk core.PubKey, awaitOpts core.AwaitOpts) (core.SignedData, error) { for _, sel := range selections { - if sel.SubcommitteeIndex == uint64(subcommIdx) { + if sel.SubcommitteeIndex == uint64(awaitOpts.SubcommIdx) { return core.NewSyncCommitteeSelection(sel), nil } } @@ -3561,3 +3561,189 @@ func registerAwaitEPBSProposal(c *validatorapi.Component, fn func(ctx context.Co return core.VersionedProposal{Version: proposal.Version, EPBS: proposal}, nil }) } + +func TestComponent_SubmitBuilderPreferences(t *testing.T) { + ctx := context.Background() + + secret, err := tbls.GenerateSecretKey() + require.NoError(t, err) + + pubkey, err := tbls.SecretToPublicKey(secret) + require.NoError(t, err) + + const shareIdx = 1 + + corePubKey, err := core.PubKeyFromBytes(pubkey[:]) + require.NoError(t, err) + + allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls + + bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSetA)) + require.NoError(t, err) + + vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, false, 30000000) + require.NoError(t, err) + + currentSlot, err := validatorapi.SlotFromTimestamp(ctx, bmock, time.Now()) + require.NoError(t, err) + + newEntry := func(data string, slot eth2p0.Slot) *gloas.BuilderPreferencesEntry { + auth := &gloas.BuilderRequestAuth{Data: []byte(data), Slot: slot} + + root, err := auth.HashTreeRoot() + require.NoError(t, err) + + sigData, err := signing.GetDataRoot(ctx, bmock, signing.DomainBuilderRequestAuth, 0, root) + require.NoError(t, err) + + sig, err := tbls.Sign(secret, sigData[:]) + require.NoError(t, err) + + return &gloas.BuilderPreferencesEntry{ + ProposerPubkey: eth2p0.BLSPubKey(pubkey), + URL: []byte("https://" + data), + Auth: &gloas.SignedBuilderRequestAuth{Message: auth, Signature: eth2p0.BLSSignature(sig)}, + MaxExecutionPayment: 7, + } + } + + type submission struct { + duty core.Duty + set core.ParSignedDataSet + } + + var submitted []submission + + vapi.Subscribe(func(_ context.Context, duty core.Duty, set core.ParSignedDataSet) error { + submitted = append(submitted, submission{duty: duty, set: set}) + + return nil + }) + + submit := func(entries ...*gloas.BuilderPreferencesEntry) error { + return vapi.SubmitBuilderPreferences(ctx, ð2api.SubmitBuilderPreferencesOpts{Preferences: entries}) + } + + // Each builder's auth is forwarded on its own, keyed by the proposal slot. + slot := currentSlot + 1 + entryA := newEntry("relay-a.example.com", slot) + entryB := newEntry("relay-b.example.com", slot) + require.NoError(t, submit(entryA, entryB)) + require.Len(t, submitted, 2) + + for i, entry := range []*gloas.BuilderPreferencesEntry{entryA, entryB} { + require.Equal(t, core.NewBuilderPreferencesDuty(uint64(slot)), submitted[i].duty) + require.Equal(t, core.ParSignedDataSet{corePubKey: core.NewPartialSignedBuilderRequestAuth(entry.Auth, shareIdx)}, submitted[i].set) + } + + // Faulty entries are skipped with a warning, not forwarded and not failing the batch: + // already-started and far-future slots, invalid signatures, unknown keys and nil entries. + invalidSig := newEntry("relay-a.example.com", slot) + invalidSig.Auth.Message.Data = []byte("relay-c.example.com") + + unknownKey := newEntry("relay-a.example.com", slot) + unknownKey.ProposerPubkey = testutil.RandomEth2PubKey(t) + + require.NoError(t, submit( + newEntry("relay-a.example.com", currentSlot), + newEntry("relay-a.example.com", currentSlot+100_000), + invalidSig, + unknownKey, + &gloas.BuilderPreferencesEntry{}, + nil, + )) + require.Len(t, submitted, 2) + + // Requests exceeding the spec list limit are rejected. + _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, bmock) + require.NoError(t, err) + + tooMany := make([]*gloas.BuilderPreferencesEntry, 64*2*slotsPerEpoch+1) + require.ErrorContains(t, submit(tooMany...), "too many builder preferences") +} + +func TestComponent_EPBSProposalBuilderRequestAuths(t *testing.T) { + ctx := context.Background() + + eth2Cl, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + const ( + slot = 123 + shareIdx = 1 + ) + + component, err := validatorapi.NewComponentInsecure(t, eth2Cl, shareIdx) + require.NoError(t, err) + + pubkey := testutil.RandomCorePubKey(t) + + component.RegisterGetDutyDefinition(func(context.Context, core.Duty) (core.DutyDefinitionSet, error) { + return core.DutyDefinitionSet{pubkey: nil}, nil + }) + + block := testutil.RandomGloasBeaconBlock() + block.Slot = slot + + registerAwaitEPBSProposal(component, func(context.Context, uint64) (*eth2api.VersionedEPBSProposal, error) { + return ð2api.VersionedEPBSProposal{Version: eth2spec.DataVersionGloas, Gloas: block}, nil + }) + + newAuth := func(data string, authSlot eth2p0.Slot) *gloas.SignedBuilderRequestAuth { + return &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{Data: []byte(data), Slot: authSlot}, + Signature: testutil.RandomEth2Signature(), + } + } + + authA := newAuth("relay-a.example.com", slot) + authB := newAuth("relay-b.example.com", slot) + + var submitted []core.ParSignedDataSet + + component.Subscribe(func(_ context.Context, duty core.Duty, set core.ParSignedDataSet) error { + if duty.Type == core.DutyBuilderPreferences { + require.Equal(t, core.NewBuilderPreferencesDuty(slot), duty) + + submitted = append(submitted, set) + } + + return nil + }) + + // A validator client without ahead-of-time builder preferences authorizes the builders + // inline in the block production request. Entries without an auth, or with an auth for + // another slot, are skipped without failing the proposal. + _, err = component.EPBSProposal(ctx, ð2api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: testutil.RandomEth2Signature(), + BuilderConfig: &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{ + {URL: []byte("https://relay-a.example.com"), Auth: authA}, + {URL: []byte("https://relay-b.example.com"), Auth: authB}, + {URL: []byte("https://relay-c.example.com")}, + {URL: []byte("https://relay-d.example.com"), Auth: newAuth("relay-d.example.com", slot+1)}, + }}, + }) + require.NoError(t, err) + + require.Equal(t, []core.ParSignedDataSet{ + {pubkey: core.NewPartialSignedBuilderRequestAuth(authA, shareIdx)}, + {pubkey: core.NewPartialSignedBuilderRequestAuth(authB, shareIdx)}, + }, submitted) + + // The inline auths are optional, so a subscriber failing on them doesn't fail the proposal. + component.Subscribe(func(_ context.Context, duty core.Duty, _ core.ParSignedDataSet) error { + if duty.Type == core.DutyBuilderPreferences { + return errors.New("subscriber error") + } + + return nil + }) + + _, err = component.EPBSProposal(ctx, ð2api.EPBSProposalOpts{ + Slot: slot, + RandaoReveal: testutil.RandomEth2Signature(), + BuilderConfig: &gloas.BuilderConfig{Builders: []*gloas.BuilderEntry{{URL: []byte("https://relay-a.example.com"), Auth: authA}}}, + }) + require.NoError(t, err) +} diff --git a/docs/architecture.md b/docs/architecture.md index 31766f4f2..4a6406e74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -137,6 +137,9 @@ type Duty struct { - `DutySyncContribution = 12`: Sync contribution duty - `DutyInfoSync = 13`: Sending versions of peers in a cluster over wire - `DutyPayloadAttestation = 14`: Payload timeliness committee (PTC) attestation duty (gloas/ePBS) +- `DutyProposerPreferences = 15`: Proposer preferences (fee recipient, gas limit) signed ahead of a proposal (gloas) +- `DutyExecutionPayloadEnvelope = 16`: Revealing the execution payload envelope of a self-built block (gloas/ePBS) +- `DutyBuilderPreferences = 17`: Builder request auths authorizing each configured builder for a proposal slot (gloas/ePBS) > ℹ️ Duty is on a cluster level, not a DV level. A duty defines the “unit of work” for the whole cluster, > not just a single DV. This allows the workflow to aggregate and batch multiple DVs in some steps, specifically consensus. @@ -789,6 +792,9 @@ The validator API intercepts the following endpoints (all other calls are transp | `GET /eth/v1/validator/attestation_data` | Served from `DutyDB.AwaitAttestation` by `slot,committee_index`. | | `POST /eth/v2/beacon/pool/attestations` | `DutyAttester`: DV pubkey inferred via `DutyDB.PubKeyByAttestation`; partial signatures stored in `ParSigDB` (v1 returns 404). | | `GET /eth/v3/validator/blocks/{slot}` | `DutyProposer`/`DutyRandao`: verifies the partial randao reveal, submits it to `ParSigDB` for async aggregation, then blocks on `DutyDB.AwaitVersionedProposal` (v2 404). | +| `POST /eth/v4/validator/blocks/{slot}` | As v3 from the gloas fork; `DutyBuilderPreferences`: builder request auths sent inline in the builder config body are submitted to `ParSigDB`. | +| `POST /eth/v1/validator/proposer_preferences` | `DutyProposerPreferences`: partial signatures stored in `ParSigDB`; the aggregate is submitted to the beacon node by `Bcast`. | +| `POST /eth/v1/validator/builder_preferences` | `DutyBuilderPreferences`: partial builder request auths stored in `ParSigDB` per builder; aggregates are submitted by `Bcast` and attached to v4 proposal requests. | | `POST /eth/v1,v2/beacon/blocks` | `DutyProposer`: DV pubkey looked up via the scheduler's `GetDutyDefinition`; partial signature stored in `ParSigDB`. | | `POST /eth/v1,v2/beacon/blinded_blocks` | Same as above; blinded proposals are also `DutyProposer` (`Blinded` flag set on `VersionedSignedProposal`). | | `POST /eth/v1/beacon/pool/voluntary_exits` | `DutyExit`: partial signature stored in `ParSigDB`. | diff --git a/docs/metrics.md b/docs/metrics.md index f6ae93f76..95a690925 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -67,6 +67,7 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance. | `cluster_validators` | Gauge | Number of validators in the cluster lock | | | `core_bcast_broadcast_delay_seconds` | Histogram | Duty broadcast delay since the expected duty submission in seconds by type | `duty` | | `core_bcast_broadcast_total` | Counter | The total count of successfully broadcast duties by type | `duty` | +| `core_bcast_builder_preferences_rejected_total` | Counter | The total count of builder preferences entries rejected by builder URL | `builder` | | `core_consensus_decided_leader_index` | Gauge | Index of the decided leader by protocol and duty | `protocol, duty` | | `core_consensus_decided_rounds` | Gauge | Number of decided rounds by protocol, duty, and timer | `protocol, duty, timer` | | `core_consensus_duration_seconds` | Histogram | Duration of the consensus process by protocol, duty, and timer | `protocol, duty, timer` | @@ -97,6 +98,7 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance. | `core_sigagg_slot_aggregation_seconds` | Histogram | Total duration to aggregate all validators for a duty in a slot, in seconds | `duty` | | `core_tracker_attestation_expect_total` | Counter | Total number of expected attestations for the slot (counts individual attestations, not duties) | | | `core_tracker_attestation_success_total` | Counter | Total number of successful attestations for the slot (counts individual attestations, not duties) | | +| `core_tracker_builder_request_auths_total` | Counter | Total number of partially signed builder request auths by peer and builder host, those for builders not configured on this node counted as unknown | `peer, builder` | | `core_tracker_expect_duties_total` | Counter | Total number of expected duties (failed + success) by type | `duty` | | `core_tracker_failed_duties_total` | Counter | Total number of failed duties by type | `duty` | | `core_tracker_failed_duty_reasons_total` | Counter | Total number of failed duties by type and reason code | `duty, reason` | @@ -111,6 +113,7 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance. | `core_tracker_participation_total` | Counter | Total number of successful participations by peer and duty type | `duty, peer` | | `core_tracker_success_duties_total` | Counter | Total number of successful duties by type | `duty` | | `core_tracker_unexpected_events_total` | Counter | Total number of unexpected events by peer | `peer` | +| `core_validatorapi_builder_preferences_mismatch_total` | Counter | Total number of builder preferences submitted by the VC that mismatch this node`s builders, by builder host and reason: a configured builder without a request auth (missing) or an auth for a builder not configured (unknown) | `builder, reason` | | `core_validatorapi_proposal_form` | Gauge | The form the validator client requested the latest gloas proposal in: none requested yet (0), stateless with its execution payload, which waits for the shared payload contents (1), or stateful (2) | | | `core_validatorapi_proposer_preferences_mismatch_total` | Counter | Total number of proposer preferences submitted by the VC that mismatch the cluster-lock value, by field | `field` | | `core_validatorapi_proxy_request_latency_seconds` | Histogram | The validatorapi proxy request latencies in seconds by path | `path` | diff --git a/eth2util/builderauth.go b/eth2util/builderauth.go new file mode 100644 index 000000000..9c6c813e4 --- /dev/null +++ b/eth2util/builderauth.go @@ -0,0 +1,81 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package eth2util + +import ( + "fmt" + "net/netip" + "strings" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/z" +) + +// BuilderAuthData returns the default builder request auth data of the builder URL: its lowercase +// ASCII hostname, with an IPv6 literal compressed inside brackets, as defined by the gloas +// builder-specs. Like the spec's urlsplit, the hostname keeps its spelling otherwise, so it matches +// the bytes validator clients sign. +// See https://github.com/ethereum/builder-specs/blob/61aeca42d9ba8eb467ae230d191ebc1c3f38107d/specs/gloas/validator.md#default-auth-data +func BuilderAuthData(builderURL string) ([]byte, error) { + // urlsplit removes ASCII tab and newline characters anywhere in the URL. + url := strings.NewReplacer("\t", "", "\r", "", "\n", "").Replace(builderURL) + + _, rest, ok := strings.Cut(url, "://") + if !ok { + return nil, errors.New("builder URL has no scheme", z.Str("url", builderURL)) + } + + authority, _, _ := strings.Cut(rest, "/") + authority, _, _ = strings.Cut(authority, "?") + authority, _, _ = strings.Cut(authority, "#") + + if i := strings.LastIndex(authority, "@"); i >= 0 { + authority = authority[i+1:] + } + + var host string + + if literal, ok := strings.CutPrefix(authority, "["); ok { + literal, _, ok = strings.Cut(literal, "]") + if !ok { + return nil, errors.New("builder URL has an unterminated IPv6 literal", z.Str("url", builderURL)) + } + + addr, err := netip.ParseAddr(literal) + if err != nil || !addr.Is6() || addr.Zone() != "" { + return nil, errors.New("builder URL has an invalid IPv6 literal", z.Str("url", builderURL)) + } + + host = "[" + compressIPv6(addr) + "]" + } else { + host, _, _ = strings.Cut(authority, ":") + } + + if host == "" || !isASCII(host) { + return nil, errors.New("builder URL has no ASCII hostname", z.Str("url", builderURL)) + } + + return []byte(strings.ToLower(host)), nil +} + +// compressIPv6 returns the RFC 5952 compressed form of the IPv6 address using hexadecimal groups +// only: netip writes IPv4-mapped addresses in the mixed IPv4 notation, which builder-specs excludes. +func compressIPv6(addr netip.Addr) string { + if !addr.Is4In6() { + return addr.String() + } + + b := addr.As16() + + return fmt.Sprintf("::ffff:%x:%x", uint16(b[12])<<8|uint16(b[13]), uint16(b[14])<<8|uint16(b[15])) +} + +func isASCII(s string) bool { + for i := range len(s) { + if s[i] >= 0x80 { + return false + } + } + + return true +} diff --git a/eth2util/builderauth_test.go b/eth2util/builderauth_test.go new file mode 100644 index 000000000..b20207b88 --- /dev/null +++ b/eth2util/builderauth_test.go @@ -0,0 +1,64 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package eth2util_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/eth2util" +) + +// The vectors mirror Lighthouse's default auth data tests at commit +// c8ddac7b79d49812283873eb6625149ef0d27f95 (common/builder_types/src/builder_url.rs), so charon +// derives the same bytes as the validator clients signing the builder request auths. + +func TestBuilderAuthDataIgnoresURLSpelling(t *testing.T) { + for _, url := range []string{ + "https://builder.example.com", + "https://builder.example.com/", + "HTTPS://Builder.Example.com:443/bids?x=1#fragment", + "https://user:pw@builder.example.com:8080/", + "https://Builder.Example.com\r\n", + "https:/\t/Buil\tder.Example.com/", + } { + data, err := eth2util.BuilderAuthData(url) + require.NoError(t, err, url) + require.Equal(t, []byte("builder.example.com"), data, url) + } +} + +func TestBuilderAuthDataHostnames(t *testing.T) { + for _, test := range []struct { + url string + expected string + }{ + {"https://10.0.0.5:18550/eth/v1/builder", "10.0.0.5"}, + {"https://[0:0:0:0:0:0:0:1]:8443/", "[::1]"}, + {"https://[::ffff:192.0.2.1]/", "[::ffff:c000:201]"}, + {"https://[2001:0DB8:0:0:1:0:0:1]/", "[2001:db8::1:0:0:1]"}, + {"https://XN--BCHER-KVA.example/", "xn--bcher-kva.example"}, + {"https://builder.example/\u8def\u5f91", "builder.example"}, // Non-ASCII path, hostname unaffected. + {"http://127.1/", "127.1"}, + {"http://0177.0.0.1/", "0177.0.0.1"}, + {"https://%65XAMPLE.com/", "%65xample.com"}, + } { + data, err := eth2util.BuilderAuthData(test.url) + require.NoError(t, err, test.url) + require.Equal(t, test.expected, string(data), test.url) + } +} + +func TestBuilderAuthDataRequiresASCIIHostname(t *testing.T) { + for _, url := range []string{ + "", + "not a URL", + "mailto:builder@example.com", + "https://bücher.example/", + "https:///path", + } { + _, err := eth2util.BuilderAuthData(url) + require.Error(t, err, url) + } +} diff --git a/eth2util/signing/signing.go b/eth2util/signing/signing.go index 254d76e1f..14e218913 100644 --- a/eth2util/signing/signing.go +++ b/eth2util/signing/signing.go @@ -35,8 +35,13 @@ const ( DomainPTCAttester DomainName = "DOMAIN_PTC_ATTESTER" DomainProposerPreferences DomainName = "DOMAIN_PROPOSER_PREFERENCES" DomainBeaconBuilder DomainName = "DOMAIN_BEACON_BUILDER" + DomainBuilderRequestAuth DomainName = "DOMAIN_BUILDER_REQUEST_AUTH" ) +// builderRequestAuthDomainType is DOMAIN_BUILDER_REQUEST_AUTH as defined by the gloas +// builder-specs. It is a builder API domain, so most beacon nodes don't publish it in their spec. +var builderRequestAuthDomainType = eth2p0.DomainType{0x0b, 0x00, 0x00, 0x01} + // GetDomain returns the beacon domain for the provided type. func GetDomain(ctx context.Context, eth2Cl eth2wrap.Client, name DomainName, epoch eth2p0.Epoch) (eth2p0.Domain, error) { resp, err := eth2Cl.Spec(ctx, ð2api.SpecOpts{}) @@ -47,6 +52,10 @@ func GetDomain(ctx context.Context, eth2Cl eth2wrap.Client, name DomainName, epo spec := resp.Data domainType, ok := spec[string(name)] + if !ok && name == DomainBuilderRequestAuth { + domainType, ok = builderRequestAuthDomainType, true + } + if !ok { return eth2p0.Domain{}, errors.New("domain type not found") } @@ -61,9 +70,34 @@ func GetDomain(ctx context.Context, eth2Cl eth2wrap.Client, name DomainName, epo return eth2Cl.GenesisDomain(ctx, domainTyped) } + if name == DomainBuilderRequestAuth { + return builderRequestAuthDomain(ctx, eth2Cl, domainTyped) + } + return eth2Cl.Domain(ctx, domainTyped, epoch) } +// builderRequestAuthDomain returns the fork-independent builder request auth domain: the genesis +// fork version and a zero genesis validators root. The go-eth2-client GenesisDomain only uses a +// zero genesis validators root for DOMAIN_APPLICATION_BUILDER, so it can't be used here. +func builderRequestAuthDomain(ctx context.Context, eth2Cl eth2wrap.Client, domainType eth2p0.DomainType) (eth2p0.Domain, error) { + genesis, err := eth2Cl.Genesis(ctx, ð2api.GenesisOpts{}) + if err != nil { + return eth2p0.Domain{}, err + } + + forkDataRoot, err := (ð2p0.ForkData{CurrentVersion: genesis.Data.GenesisForkVersion}).HashTreeRoot() + if err != nil { + return eth2p0.Domain{}, errors.Wrap(err, "hash fork data") + } + + var domain eth2p0.Domain + copy(domain[:], domainType[:]) + copy(domain[4:], forkDataRoot[:]) + + return domain, nil +} + // GetDataRoot wraps the signing root with the domain and returns signing data hash tree root. // The result should be identical to what was signed by the VC. func GetDataRoot(ctx context.Context, eth2Cl eth2wrap.Client, name DomainName, epoch eth2p0.Epoch, root eth2p0.Root) ([32]byte, error) { diff --git a/eth2util/signing/signing_test.go b/eth2util/signing/signing_test.go index f89c4fcc9..e0a2d98aa 100644 --- a/eth2util/signing/signing_test.go +++ b/eth2util/signing/signing_test.go @@ -128,3 +128,38 @@ func TestConstantApplicationBuilder(t *testing.T) { require.Equal(t, expect, domain, "domain for fork schedule %d", i) } } + +func TestGetDomainBuilderRequestAuth(t *testing.T) { + // builder-specs gloas defines DOMAIN_BUILDER_REQUEST_AUTH as DomainType('0x0B000001'), + // computed with the genesis fork version and a zero genesis validators root. + // See https://github.com/ethereum/builder-specs/blob/61aeca42d9ba8eb467ae230d191ebc1c3f38107d/specs/gloas/builder.md + bmock, err := beaconmock.New(t.Context()) + require.NoError(t, err) + + genesis, err := bmock.Genesis(t.Context(), ð2api.GenesisOpts{}) + require.NoError(t, err) + require.NotZero(t, genesis.Data.GenesisValidatorsRoot) + + forkDataRoot, err := (ð2p0.ForkData{CurrentVersion: genesis.Data.GenesisForkVersion}).HashTreeRoot() + require.NoError(t, err) + + var expect eth2p0.Domain + copy(expect[:], []byte{0x0b, 0x00, 0x00, 0x01}) + copy(expect[4:], forkDataRoot[:28]) + + // Most beacon nodes don't publish the builder API domain in their spec, so the + // builder-specs value is used. The domain doesn't depend on the epoch. + for _, epoch := range []eth2p0.Epoch{0, 100} { + domain, err := signing.GetDomain(t.Context(), bmock, signing.DomainBuilderRequestAuth, epoch) + require.NoError(t, err) + require.Equal(t, expect, domain) + } + + // A beacon node publishing the domain is honoured. + bmock, err = beaconmock.New(t.Context(), beaconmock.WithSpecOverride("DOMAIN_BUILDER_REQUEST_AUTH", "0x0b000001")) + require.NoError(t, err) + + domain, err := signing.GetDomain(t.Context(), bmock, signing.DomainBuilderRequestAuth, 100) + require.NoError(t, err) + require.Equal(t, expect, domain) +} diff --git a/go.mod b/go.mod index a53bb4c6e..6c7aad96b 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/libp2p/go-libp2p v0.48.0 github.com/libp2p/go-msgio v0.3.0 github.com/multiformats/go-multiaddr v0.16.1 - github.com/pk910/dynamic-ssz v1.3.2 + github.com/pk910/dynamic-ssz v1.4.0 github.com/prometheus/client_golang v1.25.0 github.com/prometheus/client_model v0.6.3 github.com/protolambda/eth2-shuffle v1.1.0 @@ -90,7 +90,6 @@ require ( github.com/bufbuild/buf v1.72.0 // indirect github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672 // indirect github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect - github.com/casbin/govaluate v1.10.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chigopher/pathlib v0.19.1 // indirect @@ -225,7 +224,7 @@ require ( github.com/pion/transport/v4 v4.1.0 // indirect github.com/pion/turn/v4 v4.0.2 // indirect github.com/pion/webrtc/v4 v4.1.2 // indirect - github.com/pk910/hashtree-bindings v0.2.2 // indirect + github.com/pk910/hashtree-bindings v0.2.6 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/common v0.72.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect @@ -291,7 +290,7 @@ require ( replace github.com/coinbase/kryptology => github.com/ObolNetwork/kryptology v0.1.0 // We're replacing go-eth2-client with a branch off our fork. The branch is kept up to date with the latest attestantio versions. -replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.29.0-obol.5-gloas //nolint +replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.30.1-obol.1-gloas //nolint tool ( github.com/bufbuild/buf/cmd/buf diff --git a/go.sum b/go.sum index 5f5cdeef9..3436e1f03 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ObolNetwork/go-eth2-client v0.29.0-obol.5-gloas h1:JGblooTW3gVhM+KiRgyRBG/AagRKJRdbePjspRqpxAQ= -github.com/ObolNetwork/go-eth2-client v0.29.0-obol.5-gloas/go.mod h1:yhVnKAzIsFhtawbq6k/rA/Dy4vsPpu2Z2cGdQVrIjd0= +github.com/ObolNetwork/go-eth2-client v0.30.1-obol.1-gloas h1:4rwboz5phEgGpphFhGo0TJTWpJvVOtT5k0YHZs1qw28= +github.com/ObolNetwork/go-eth2-client v0.30.1-obol.1-gloas/go.mod h1:IcbypY5iD/c/T5OLWVuZyWS8kM8HXkGc0ZW12uikfys= github.com/ObolNetwork/kryptology v0.1.0 h1:AhoG4My70+xMhEJSpVaJay/t+T/vIUNHQYLjsDJHulI= github.com/ObolNetwork/kryptology v0.1.0/go.mod h1:/Wl7Js2f676GyXZDTaojf/O+l0fxFPWudbyjdFhkpSA= github.com/OffchainLabs/go-bitfield v0.0.0-20251031151322-f427d04d8506 h1:d/SJkN8/9Ca+1YmuDiUJxAiV4w/a9S8NcsG7GMQSrVI= @@ -87,8 +87,6 @@ github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjL github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 h1:oe6fCvaEpkhyW3qAicT0TnGtyht/UrgvOwMcEgLb7Aw= github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3/go.mod h1:qdP0gaj0QtgX2RUZhnlVrceJ+Qln8aSlDyJwelLLFeM= -github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= -github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= @@ -480,10 +478,10 @@ github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= -github.com/pk910/dynamic-ssz v1.3.2 h1:65UR/O+ss+U2Dn86Rdl7LwehHo3u2ElutduS/pcuUXE= -github.com/pk910/dynamic-ssz v1.3.2/go.mod h1:lqmnou2bjr2UWQ3C/L3082TGW0SFl/SwT7ionwM0+FU= -github.com/pk910/hashtree-bindings v0.2.2 h1:gkczxxekBW2NeMK9N3OLj7Jepe7zPmJGVwr8LyofGsA= -github.com/pk910/hashtree-bindings v0.2.2/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= +github.com/pk910/dynamic-ssz v1.4.0 h1:uwDOiBR/gmbC08gGo1TA/SLD17ArJyLKi0PHaQmuZEM= +github.com/pk910/dynamic-ssz v1.4.0/go.mod h1:mnRbhjX+4YMyP4jYMncTUA3aDpMzBLqAbjcvfCyOyRI= +github.com/pk910/hashtree-bindings v0.2.6 h1:BjRQho04nWzGrXwBCLVTyQRNV30O59iPcvQRcidFt2o= +github.com/pk910/hashtree-bindings v0.2.6/go.mod h1:zrWt88783JmhBfcgni6kkIMYRdXTZi/FL//OyI5T/l4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/testutil/beaconmock/beaconmock.go b/testutil/beaconmock/beaconmock.go index 234c82d12..08d99525d 100644 --- a/testutil/beaconmock/beaconmock.go +++ b/testutil/beaconmock/beaconmock.go @@ -34,7 +34,6 @@ import ( eth2spec "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" "github.com/attestantio/go-eth2-client/spec/electra" - "github.com/attestantio/go-eth2-client/spec/gloas" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/jonboulle/clockwork" @@ -207,7 +206,8 @@ type Mock struct { SignedBeaconBlockFunc func(ctx context.Context, blockID string) (*eth2spec.VersionedSignedBeaconBlock, error) ProposerDutiesFunc func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) ProposerDutiesV2Func func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) - SubmitProposerPreferencesFunc func(context.Context, []*gloas.SignedProposerPreferences) error + SubmitProposerPreferencesFunc func(context.Context, *eth2api.SubmitProposerPreferencesOpts) error + SubmitBuilderPreferencesFunc func(context.Context, *eth2api.SubmitBuilderPreferencesOpts) error SubmitExecutionPayloadEnvelopeFunc func(context.Context, *eth2api.SubmitExecutionPayloadEnvelopeOpts) error SignedExecutionPayloadEnvelopeFunc func(ctx context.Context, blockID string) (*eth2spec.VersionedSignedExecutionPayloadEnvelope, error) CachedProposerDutiesFunc func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) (eth2wrap.ProposerDutyWithMeta, error) @@ -349,8 +349,12 @@ func (m Mock) ProposerDutiesV2(ctx context.Context, opts *eth2api.ProposerDuties return wrapResponseWithMetadata(duties), nil } -func (m Mock) SubmitProposerPreferences(ctx context.Context, preferences []*gloas.SignedProposerPreferences) error { - return m.SubmitProposerPreferencesFunc(ctx, preferences) +func (m Mock) SubmitProposerPreferences(ctx context.Context, opts *eth2api.SubmitProposerPreferencesOpts) error { + return m.SubmitProposerPreferencesFunc(ctx, opts) +} + +func (m Mock) SubmitBuilderPreferences(ctx context.Context, opts *eth2api.SubmitBuilderPreferencesOpts) error { + return m.SubmitBuilderPreferencesFunc(ctx, opts) } func (m Mock) SubmitExecutionPayloadEnvelope(ctx context.Context, opts *eth2api.SubmitExecutionPayloadEnvelopeOpts) error { diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index 947b7d710..98fabc80b 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -736,7 +736,10 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo ProposerDutiesV2Func: func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { return []*eth2v1.ProposerDuty{}, nil }, - SubmitProposerPreferencesFunc: func(context.Context, []*gloas.SignedProposerPreferences) error { + SubmitProposerPreferencesFunc: func(context.Context, *eth2api.SubmitProposerPreferencesOpts) error { + return nil + }, + SubmitBuilderPreferencesFunc: func(context.Context, *eth2api.SubmitBuilderPreferencesOpts) error { return nil }, SubmitExecutionPayloadEnvelopeFunc: func(context.Context, *eth2api.SubmitExecutionPayloadEnvelopeOpts) error { diff --git a/testutil/integration/simnet_test.go b/testutil/integration/simnet_test.go index fa623a86a..92de367d5 100644 --- a/testutil/integration/simnet_test.go +++ b/testutil/integration/simnet_test.go @@ -89,6 +89,13 @@ func TestSimnetDuties(t *testing.T) { duties: []core.DutyType{core.DutyProposerPreferences}, vcType: vcVmock, }, + { + name: "builder preferences with mock VCs", + scheduledType: core.DutyBuilderPreferences, + // The mock VCs also submit proposer preferences for the proposal slot they authorize builders for. + duties: []core.DutyType{core.DutyBuilderPreferences, core.DutyProposerPreferences}, + vcType: vcVmock, + }, { name: "gloas epbs proposer with mock VCs", scheduledType: core.DutyProposer, @@ -174,9 +181,9 @@ func TestSimnetDuties(t *testing.T) { ) } - if test.scheduledType == core.DutyProposerPreferences { - // Proposer preferences only exist from gloas onwards, activate the fork and - // enable v2 proposer duties, the source of the slots the preferences target. + if test.scheduledType == core.DutyProposerPreferences || test.scheduledType == core.DutyBuilderPreferences { + // Proposer and builder preferences only exist from gloas onwards, activate the fork + // and enable v2 proposer duties, the source of the slots the preferences target. args.BMockOpts = append(args.BMockOpts, beaconmock.WithSpecOverride("GLOAS_FORK_VERSION", "0x07000000"), beaconmock.WithSpecOverride("GLOAS_FORK_EPOCH", "0"), @@ -184,6 +191,11 @@ func TestSimnetDuties(t *testing.T) { ) } + if test.scheduledType == core.DutyBuilderPreferences { + // The mock VCs authorize the builder charon is configured with. + args.BuilderURLs = []string{"https://relay-a.example.com"} + } + expect := newSimnetExpect(args.N, test.duties...) testSimnet(t, args, expect) }) @@ -200,6 +212,7 @@ type simnetArgs struct { Lock cluster.Lock ErrChan chan error BuilderAPI bool + BuilderURLs []string SyntheticProposals bool VoluntaryExit bool } @@ -364,6 +377,7 @@ func testSimnet(t *testing.T, args simnetArgs, expect *simnetExpect) { Relays: []string{relayAddr}, }, BuilderAPI: args.BuilderAPI, + BuilderURLs: args.BuilderURLs, SyntheticBlockProposals: args.SyntheticProposals, } diff --git a/testutil/random.go b/testutil/random.go index d615a762b..12cde6093 100644 --- a/testutil/random.go +++ b/testutil/random.go @@ -1237,6 +1237,16 @@ func RandomProposerPreferences() *gloas.SignedProposerPreferences { } } +func RandomSignedBuilderRequestAuth() *gloas.SignedBuilderRequestAuth { + return &gloas.SignedBuilderRequestAuth{ + Message: &gloas.BuilderRequestAuth{ + Data: []byte("builder.example.com"), + Slot: RandomSlot(), + }, + Signature: RandomEth2Signature(), + } +} + func RandomSyncCommitteeMessage() *altair.SyncCommitteeMessage { return &altair.SyncCommitteeMessage{ Slot: RandomSlot(), diff --git a/testutil/validatormock/builderpreferences.go b/testutil/validatormock/builderpreferences.go new file mode 100644 index 000000000..e9a6eccbc --- /dev/null +++ b/testutil/validatormock/builderpreferences.go @@ -0,0 +1,103 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package validatormock + +import ( + "context" + + eth2api "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/spec/gloas" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/eth2wrap" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/signing" +) + +// builderPrefsMaxExecutionPayment is the maximum execution payment the mock submits with each +// builder preferences entry. Charon submits its own configured value instead. +const builderPrefsMaxExecutionPayment eth2p0.Gwei = 1_000_000_000 + +// BuilderPreferences submits the builder preferences of the provided future proposal slot: a +// builder request auth for each builder URL. It is stateless and does nothing if no active +// validator is the proposer for the slot or no builders are configured. +func BuilderPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc SignFunc, slot eth2p0.Slot, builderURLs []string) error { + if len(builderURLs) == 0 { + return nil + } + + valMap, err := eth2Cl.ActiveValidators(ctx) + if err != nil { + return err + } + + _, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl) + if err != nil { + return err + } + + epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + + var indexes []eth2p0.ValidatorIndex + for index := range valMap { + indexes = append(indexes, index) + } + + eth2Resp, err := eth2Cl.ProposerDutiesV2(ctx, ð2api.ProposerDutiesOpts{ + Epoch: epoch, + Indices: indexes, + }) + if err != nil { + return err + } + + var entries []*gloas.BuilderPreferencesEntry + + for _, duty := range eth2Resp.Data { + if duty == nil { + return errors.New("proposer duty is nil") + } + + if duty.Slot != slot { + continue + } + + for _, url := range builderURLs { + data, err := eth2util.BuilderAuthData(url) + if err != nil { + return err + } + + msg := &gloas.BuilderRequestAuth{Data: data, Slot: slot} + + root, err := msg.HashTreeRoot() + if err != nil { + return errors.Wrap(err, "hash builder request auth") + } + + sigData, err := signing.GetDataRoot(ctx, eth2Cl, signing.DomainBuilderRequestAuth, epoch, root) + if err != nil { + return err + } + + sig, err := signFunc(duty.PubKey, sigData[:]) + if err != nil { + return err + } + + entries = append(entries, &gloas.BuilderPreferencesEntry{ + ProposerPubkey: duty.PubKey, + URL: []byte(url), + Auth: &gloas.SignedBuilderRequestAuth{Message: msg, Signature: sig}, + MaxExecutionPayment: builderPrefsMaxExecutionPayment, + }) + } + } + + if len(entries) == 0 { + return nil + } + + return eth2Cl.SubmitBuilderPreferences(ctx, ð2api.SubmitBuilderPreferencesOpts{Preferences: entries}) +} diff --git a/testutil/validatormock/builderpreferences_test.go b/testutil/validatormock/builderpreferences_test.go new file mode 100644 index 000000000..67820a445 --- /dev/null +++ b/testutil/validatormock/builderpreferences_test.go @@ -0,0 +1,107 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package validatormock_test + +import ( + "context" + "testing" + + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/gloas" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/validatormock" +) + +func TestBuilderPreferences(t *testing.T) { + const slot = 123 + + valSet := beaconmock.ValidatorSetA + + var ( + dutyIdx eth2p0.ValidatorIndex + dutyPubkey eth2p0.BLSPubKey + ) + + for idx, val := range valSet { + dutyIdx = idx + dutyPubkey = val.Validator.PublicKey + + break + } + + sig := testutil.RandomEth2Signature() + signFunc := func(key eth2p0.BLSPubKey, _ []byte) (eth2p0.BLSSignature, error) { //nolint:unparam // The SignFunc signature requires an error. + require.Equal(t, dutyPubkey, key) + return sig, nil + } + + newMock := func(t *testing.T) beaconmock.Mock { + t.Helper() + + bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet)) + require.NoError(t, err) + + bmock.ProposerDutiesV2Func = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + return []*eth2v1.ProposerDuty{{ + PubKey: dutyPubkey, + Slot: slot, + ValidatorIndex: dutyIdx, + }}, nil + } + + return bmock + } + + urls := []string{"https://Relay-A.example.com:443/", "https://relay-b.example.com"} + + t.Run("submits an auth per builder", func(t *testing.T) { + bmock := newMock(t) + + var submitted []*gloas.BuilderPreferencesEntry + + bmock.SubmitBuilderPreferencesFunc = func(_ context.Context, opts *eth2api.SubmitBuilderPreferencesOpts) error { + submitted = opts.Preferences + return nil + } + + require.NoError(t, validatormock.BuilderPreferences(t.Context(), bmock, signFunc, slot, urls)) + require.Len(t, submitted, 2) + + for i, entry := range submitted { + require.Equal(t, dutyPubkey, entry.ProposerPubkey) + require.Equal(t, []byte(urls[i]), entry.URL) + require.Equal(t, eth2p0.Slot(slot), entry.Auth.Message.Slot) + require.Equal(t, sig, entry.Auth.Signature) + require.NotZero(t, entry.MaxExecutionPayment) + } + + // The auth data is the builder's hostname, as the validator clients derive it. + require.Equal(t, []byte("relay-a.example.com"), submitted[0].Auth.Message.Data) + require.Equal(t, []byte("relay-b.example.com"), submitted[1].Auth.Message.Data) + }) + + t.Run("no duty for slot", func(t *testing.T) { + bmock := newMock(t) + bmock.SubmitBuilderPreferencesFunc = func(context.Context, *eth2api.SubmitBuilderPreferencesOpts) error { + require.Fail(t, "unexpected submission") + return nil + } + + require.NoError(t, validatormock.BuilderPreferences(t.Context(), bmock, signFunc, slot+1, urls)) + }) + + t.Run("no builders", func(t *testing.T) { + bmock := newMock(t) + bmock.SubmitBuilderPreferencesFunc = func(context.Context, *eth2api.SubmitBuilderPreferencesOpts) error { + require.Fail(t, "unexpected submission") + return nil + } + + require.NoError(t, validatormock.BuilderPreferences(t.Context(), bmock, signFunc, slot, nil)) + }) +} diff --git a/testutil/validatormock/component.go b/testutil/validatormock/component.go index 26d68795a..06a23cb4d 100644 --- a/testutil/validatormock/component.go +++ b/testutil/validatormock/component.go @@ -41,6 +41,7 @@ func New(ctx context.Context, slotsPerEpoch uint64, slotOffsetFunc core.SlotOffsetFunc, builderAPI bool, + builderURLs []string, ) *Component { c := &Component{ eth2ClProvider: eth2ClProvider, @@ -53,6 +54,7 @@ func New(ctx context.Context, SlotOffset: slotOffsetFunc, }, builderAPI: builderAPI, + builderURLs: builderURLs, attestersBySlot: make(map[uint64]*SlotAttester), syncCommsByEpoch: make(map[uint64]*SyncCommMember), scheduled: make(chan scheduleTuple), @@ -75,6 +77,7 @@ type Component struct { meta specMeta scheduled chan scheduleTuple builderAPI bool + builderURLs []string // Mutable state. mu sync.Mutex @@ -312,6 +315,10 @@ func (m *Component) runDuty(ctx context.Context, duty core.Duty) error { if err = ProposerPreferences(ctx, eth2Cl, m.signFunc, eth2Slot); err != nil { return err } + case core.DutyBuilderPreferences: + if err = BuilderPreferences(ctx, eth2Cl, m.signFunc, eth2Slot, m.builderURLs); err != nil { + return err + } case core.DutyBuilderRegistration: // Expected duty, but no action needed in validatormock. default: @@ -453,6 +460,9 @@ var dutyStartTimeFuncsByDuty = map[core.DutyType][]dutyStartTimeFunc{ // Proposer preferences for a proposal slot in epoch E are submitted at the start of // epoch E-1, the earliest time the E-2 dependent root they sign over is anchored. core.DutyProposerPreferences: {startOfPrevEpoch}, + // Builder preferences authorize builders for a proposal slot and are submitted ahead of it + // like proposer preferences. + core.DutyBuilderPreferences: {startOfPrevEpoch}, } // startOfPrevEpoch returns the start time of the previous epoch. diff --git a/testutil/validatormock/proposerpreferences.go b/testutil/validatormock/proposerpreferences.go index d1768ee0a..733772ba6 100644 --- a/testutil/validatormock/proposerpreferences.go +++ b/testutil/validatormock/proposerpreferences.go @@ -97,7 +97,7 @@ func ProposerPreferences(ctx context.Context, eth2Cl eth2wrap.Client, signFunc S return nil } - return eth2Cl.SubmitProposerPreferences(ctx, prefs) + return eth2Cl.SubmitProposerPreferences(ctx, ð2api.SubmitProposerPreferencesOpts{Preferences: prefs}) } // dependentRoot returns the dependent root from the proposer duties response metadata, diff --git a/testutil/validatormock/proposerpreferences_test.go b/testutil/validatormock/proposerpreferences_test.go index 320725b58..686a3aa5f 100644 --- a/testutil/validatormock/proposerpreferences_test.go +++ b/testutil/validatormock/proposerpreferences_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec/gloas" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -61,8 +62,8 @@ func TestProposerPreferences(t *testing.T) { var submitted []*gloas.SignedProposerPreferences - bmock.SubmitProposerPreferencesFunc = func(_ context.Context, prefs []*gloas.SignedProposerPreferences) error { - submitted = prefs + bmock.SubmitProposerPreferencesFunc = func(_ context.Context, opts *eth2api.SubmitProposerPreferencesOpts) error { + submitted = opts.Preferences return nil } @@ -79,7 +80,7 @@ func TestProposerPreferences(t *testing.T) { t.Run("no duty for slot", func(t *testing.T) { bmock := newMock(t) - bmock.SubmitProposerPreferencesFunc = func(context.Context, []*gloas.SignedProposerPreferences) error { + bmock.SubmitProposerPreferencesFunc = func(context.Context, *eth2api.SubmitProposerPreferencesOpts) error { require.Fail(t, "unexpected submission") return nil }