Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 36 additions & 26 deletions api/client/builder/client_gloas.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"github.com/OffchainLabs/prysm/v7/api"
"github.com/OffchainLabs/prysm/v7/api/server/structs"
Expand Down Expand Up @@ -57,34 +59,39 @@ func (c *Client) getExecutionPayloadBid(
auth *ethpb.SignedRequestAuthV1,
ssz bool,
) (*ethpb.SignedExecutionPayloadBid, error) {
if auth == nil {
return nil, errors.Wrap(errMalformedRequest, "nil request auth, the builder requires an authenticated bid request")
}
accept := api.JsonMediaType
contentType := api.JsonMediaType
var body []byte
var err error
if ssz {
accept = api.OctetStreamMediaType
contentType = api.OctetStreamMediaType
body, err = auth.MarshalSSZ()
if err != nil {
return nil, errors.Wrap(err, "could not ssz encode SignedRequestAuthV1")
}
} else {
body, err = json.Marshal(structs.SignedRequestAuthFromConsensus(auth))
if err != nil {
return nil, errors.Wrap(err, "could not json encode SignedRequestAuthV1")
}
}
// The builder must respond by Date-Milliseconds plus X-Timeout-Ms, the proposer discards later responses.
timeout := c.hc.Timeout
if dl, ok := ctx.Deadline(); ok {
timeout = time.Until(dl)
}
var body []byte
opts := []reqOption{func(r *http.Request) {
r.Header.Set("Accept", accept)
r.Header.Set(api.VersionHeader, version.String(version.Gloas))
}}
if auth != nil {
var err error
contentType := api.JsonMediaType
if ssz {
contentType = api.OctetStreamMediaType
body, err = auth.MarshalSSZ()
if err != nil {
return nil, errors.Wrap(err, "could not ssz encode SignedRequestAuthV1")
}
} else {
body, err = json.Marshal(structs.SignedRequestAuthFromConsensus(auth))
if err != nil {
return nil, errors.Wrap(err, "could not json encode SignedRequestAuthV1")
}
r.Header.Set("Content-Type", contentType)
r.Header.Set("Date-Milliseconds", strconv.FormatInt(time.Now().UnixMilli(), 10))
if timeout > 0 {
r.Header.Set("X-Timeout-Ms", strconv.FormatInt(timeout.Milliseconds(), 10))
}
opts = append(opts, func(r *http.Request) {
r.Header.Set("Content-Type", contentType)
})
}
}}

path := executionPayloadBidPath(slot, parentHash, parentRoot, proposerPubkey)
raw, status, header, err := c.doWithStatus(ctx, http.MethodPost, path, bytes.NewReader(body), []int{http.StatusOK, http.StatusNoContent}, opts...)
Expand All @@ -94,9 +101,9 @@ func (c *Client) getExecutionPayloadBid(
if status == http.StatusNoContent {
return nil, nil
}
contentType := header.Get("Content-Type")
respContentType := header.Get("Content-Type")
switch {
case strings.Contains(contentType, api.JsonMediaType):
case strings.Contains(respContentType, api.JsonMediaType):
resp := &struct {
Data *structs.SignedExecutionPayloadBid `json:"data"`
}{}
Expand All @@ -107,14 +114,14 @@ func (c *Client) getExecutionPayloadBid(
return nil, errors.New("nil data in json SignedExecutionPayloadBid response")
}
return resp.Data.ToConsensus()
case strings.Contains(contentType, api.OctetStreamMediaType):
case strings.Contains(respContentType, api.OctetStreamMediaType):
bid := &ethpb.SignedExecutionPayloadBid{}
if err := bid.UnmarshalSSZ(raw); err != nil {
return nil, errors.Wrap(err, "could not ssz decode SignedExecutionPayloadBid")
}
return bid, nil
default:
return nil, errors.Errorf("builder returned status %d with unexpected Content-Type %q: %s", status, contentType, bodySnippet(raw))
return nil, errors.Errorf("builder returned status %d with unexpected Content-Type %q: %s", status, respContentType, bodySnippet(raw))
}
}

Expand Down Expand Up @@ -209,7 +216,10 @@ func (c *Client) submitBuilderPreferences(ctx context.Context, validatorPubkey [
return errors.Wrap(err, "could not json encode BuilderPreferencesRequestV1")
}
}
if _, _, err := c.do(ctx, http.MethodPost, builderPreferencesPath(validatorPubkey), bytes.NewReader(body), http.StatusAccepted, contentTypeOpts(contentType, version.Gloas)); err != nil {
// BuilderPreferencesRequestV1 is not fork-versioned, no Eth-Consensus-Version header.
if _, _, err := c.do(ctx, http.MethodPost, builderPreferencesPath(validatorPubkey), bytes.NewReader(body), http.StatusAccepted, func(r *http.Request) {
r.Header.Set("Content-Type", contentType)
}); err != nil {
return errors.Wrap(err, "error submitting builder preferences")
}
return nil
Expand Down
33 changes: 27 additions & 6 deletions api/client/builder/client_gloas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package builder

import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
"time"

"github.com/OffchainLabs/prysm/v7/api"
"github.com/OffchainLabs/prysm/v7/api/server/structs"
Expand Down Expand Up @@ -37,13 +39,21 @@ func testExecutionPayloadBid() *eth.SignedExecutionPayloadBid {
}
}

func testRequestAuth() *eth.SignedRequestAuthV1 {
return &eth.SignedRequestAuthV1{
Message: &eth.RequestAuthV1{Data: []byte("http://builder.example"), Slot: 5},
Signature: bytes.Repeat([]byte{9}, 96),
}
}

func gloasBidClient(t *testing.T, status int, contentType string, body []byte) *Client {
hc := &http.Client{
Transport: roundtrip(func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
require.NoError(t, r.Body.Close())
}
require.Equal(t, http.MethodPost, r.Method)
require.NotEqual(t, "", r.Header.Get("Date-Milliseconds"))
h := http.Header{}
if contentType != "" {
h.Set("Content-Type", contentType)
Expand Down Expand Up @@ -72,7 +82,7 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
}{Data: structs.SignedExecutionPayloadBidFromConsensus(want)})
require.NoError(t, err)
c := gloasBidClient(t, http.StatusOK, api.JsonMediaType, body)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testRequestAuth())
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, want.Message.Slot, got.Message.Slot)
Expand All @@ -84,7 +94,7 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
body, err := want.MarshalSSZ()
require.NoError(t, err)
c := gloasBidClient(t, http.StatusOK, api.OctetStreamMediaType, body)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testRequestAuth())
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, want.Message.Value, got.Message.Value)
Expand All @@ -93,15 +103,22 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {

t.Run("no bid", func(t *testing.T) {
c := gloasBidClient(t, http.StatusNoContent, "", nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testRequestAuth())
require.NoError(t, err)
require.IsNil(t, got)
})

t.Run("nil auth errors", func(t *testing.T) {
c := gloasBidClient(t, http.StatusOK, api.JsonMediaType, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
require.IsNil(t, got)
require.ErrorContains(t, "nil request auth", err)
})

t.Run("unexpected content type errors with status and body", func(t *testing.T) {
html := []byte("<!doctype html><html><head><title>Buildoor</title></head></html>")
c := gloasBidClient(t, http.StatusOK, "text/html; charset=utf-8", html)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testRequestAuth())
require.IsNil(t, got)
require.ErrorContains(t, "unexpected Content-Type", err)
require.ErrorContains(t, "text/html", err)
Expand All @@ -124,13 +141,17 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
require.NoError(t, r.Body.Close())
require.Equal(t, api.OctetStreamMediaType, r.Header.Get("Content-Type"))
require.DeepEqual(t, wantBody, body)
require.NotEqual(t, "", r.Header.Get("Date-Milliseconds"))
require.NotEqual(t, "", r.Header.Get("X-Timeout-Ms"))
h := http.Header{}
h.Set("Content-Type", api.OctetStreamMediaType)
return &http.Response{StatusCode: http.StatusOK, Header: h, Body: io.NopCloser(bytes.NewReader(sszBid)), Request: r}, nil
}),
}
c := &Client{hc: hc, baseURL: &url.URL{Host: "localhost:3500", Scheme: "http"}, sszEnabled: true}
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, auth)
deadlineCtx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
got, err := c.GetExecutionPayloadBid(deadlineCtx, slot, parentHash, parentRoot, pubkey, auth)
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, want.Message.Value, got.Message.Value)
Expand Down Expand Up @@ -161,7 +182,7 @@ func TestClient_GetExecutionPayloadBid(t *testing.T) {
}),
}
c := &Client{hc: hc, baseURL: &url.URL{Host: "localhost:3500", Scheme: "http"}, sszEnabled: true}
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, nil)
got, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, pubkey, testRequestAuth())
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, 2, reqCount)
Expand Down
63 changes: 35 additions & 28 deletions beacon-chain/builder/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package builder

import (
"context"
"fmt"
"reflect"
"sync"
"time"
Expand Down Expand Up @@ -29,18 +30,18 @@ type BlockBuilder interface {
SubmitBlindedBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock) (interfaces.ExecutionData, v1.BlobsBundler, error)
SubmitBlindedBlockPostFulu(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock) error
GetHeader(ctx context.Context, slot primitives.Slot, parentHash [32]byte, pubKey [48]byte) (builder.SignedBid, error)
GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, auths []*ethpb.SignedRequestAuthV1) ([]PayloadBid, error)
GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, entries []*ethpb.BuilderRequestEntry) ([]PayloadBid, error)
SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block interfaces.ReadOnlySignedBeaconBlock) error
SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error
SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1, url string) error
RegisterValidator(ctx context.Context, reg []*ethpb.SignedValidatorRegistrationV1) error
RegistrationByValidatorID(ctx context.Context, id primitives.ValidatorIndex) (*ethpb.ValidatorRegistrationV1, error)
Configured() bool
}

// PayloadBid carries the builder URL so the proposer can route the signed block back to the winning builder.
// PayloadBid carries the request entry so the proposer can apply per-builder policy and route the signed block.
type PayloadBid struct {
BuilderURL string
Bid *ethpb.SignedExecutionPayloadBid
Entry *ethpb.BuilderRequestEntry
Bid *ethpb.SignedExecutionPayloadBid
}

// config defines a config struct for dependencies into the service.
Expand Down Expand Up @@ -163,23 +164,26 @@ func (s *Service) SubmitBlindedBlockPostFulu(ctx context.Context, b interfaces.R
}

// Builders are queried concurrently, a failing builder drops only its own bid.
func (s *Service) GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, auths []*ethpb.SignedRequestAuthV1) ([]PayloadBid, error) {
func (s *Service) GetExecutionPayloadBid(ctx context.Context, slot primitives.Slot, parentHash, parentRoot [32]byte, proposerPubkey [48]byte, entries []*ethpb.BuilderRequestEntry) ([]PayloadBid, error) {
ctx, span := trace.StartSpan(ctx, "builder.GetExecutionPayloadBid")
defer span.End()

byURL := make(map[string]*ethpb.SignedRequestAuthV1, len(auths))
urls := make([]string, 0, len(auths))
for _, a := range auths {
url := string(a.GetMessage().GetData())
if url == "" {
// Deduplicated on the (url, auth data) pair so one builder is only asked once per route.
type entryKey struct {
url string
data string
}
seen := make(map[entryKey]bool, len(entries))
deduped := make([]*ethpb.BuilderRequestEntry, 0, len(entries))
for _, e := range entries {
k := entryKey{url: e.GetUrl(), data: string(e.GetAuth().GetMessage().GetData())}
if k.url == "" || e.GetAuth() == nil || seen[k] {
continue
}
if _, ok := byURL[url]; !ok {
byURL[url] = a
urls = append(urls, url)
}
seen[k] = true
deduped = append(deduped, e)
}
if len(urls) == 0 {
if len(deduped) == 0 {
return nil, nil
}

Expand All @@ -188,27 +192,31 @@ func (s *Service) GetExecutionPayloadBid(ctx context.Context, slot primitives.Sl
bids []PayloadBid
wg sync.WaitGroup
)
for _, url := range urls {
for _, e := range deduped {
wg.Add(1)
go func(url string) {
go func(e *ethpb.BuilderRequestEntry) {
defer wg.Done()
c, err := s.clientFor(url)
l := log.WithField("builder", logs.MaskCredentialsLogging(e.GetUrl()))
if data := string(e.GetAuth().GetMessage().GetData()); data != e.GetUrl() {
l = l.WithField("authData", fmt.Sprintf("%q", data))
}
c, err := s.clientFor(e.GetUrl())
if err != nil {
log.WithError(err).WithField("builder", logs.MaskCredentialsLogging(url)).Warn("Could not get builder client")
l.WithError(err).Warn("Could not get builder client")
return
}
bid, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, proposerPubkey, byURL[url])
bid, err := c.GetExecutionPayloadBid(ctx, slot, parentHash, parentRoot, proposerPubkey, e.GetAuth())
if err != nil {
log.WithError(err).WithField("builder", logs.MaskCredentialsLogging(url)).Warn("Could not get builder execution payload bid")
l.WithError(err).Warn("Could not get builder execution payload bid")
return
}
if bid == nil {
return
}
mu.Lock()
bids = append(bids, PayloadBid{BuilderURL: url, Bid: bid})
bids = append(bids, PayloadBid{Entry: e, Bid: bid})
mu.Unlock()
}(url)
}(e)
}
wg.Wait()
return bids, nil
Expand All @@ -230,13 +238,12 @@ func (s *Service) SubmitSignedBeaconBlock(ctx context.Context, builderURL string
return c.SubmitSignedBeaconBlock(ctx, b)
}

// Routed to the builder named in the signed request auth.
func (s *Service) SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1) error {
// The dial target is carried alongside the request, the signed auth data is opaque and may not be a URL.
func (s *Service) SubmitBuilderPreferences(ctx context.Context, validatorPubkey [48]byte, req *ethpb.BuilderPreferencesRequestV1, url string) error {
ctx, span := trace.StartSpan(ctx, "builder.SubmitBuilderPreferences")
defer span.End()
url := string(req.GetAuth().GetMessage().GetData())
if url == "" {
return errors.New("builder preferences missing builder url")
return errors.New("builder preferences missing dial url")
}
c, err := s.clientFor(url)
if err != nil {
Expand Down
Loading
Loading