Skip to content
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
8ed5f68
Add ssz support for getHeader
jtraglia Feb 5, 2025
498d8fc
Request SSZ then JSON
jtraglia Feb 5, 2025
a0cc8a0
Read response body after checking for success
jtraglia Feb 5, 2025
3005de8
Request what the client asks for first
jtraglia Feb 5, 2025
3afd060
Decode based on resp content type
jtraglia Feb 5, 2025
f65e83d
Return the best bid in the request encoding
jtraglia Feb 5, 2025
2073a41
Fix some nits
jtraglia Feb 5, 2025
b4b9088
Fix debug message
jtraglia Feb 5, 2025
b1cda56
Always request header from relay in SSZ first
jtraglia Feb 5, 2025
097c625
Merge branch 'develop' into ssz-get-header
jtraglia Feb 5, 2025
db3f598
Revert "Always request header from relay in SSZ first"
jtraglia Feb 5, 2025
6545540
Update about request (SSZ and JSON) order
jtraglia Feb 5, 2025
735bcbd
Update mock_relay.defaultHandleGetHeader to handle SSZ
jtraglia Feb 5, 2025
881cff0
Add a test & fix a couple bugs
jtraglia Feb 5, 2025
8d1073f
Fix lint
jtraglia Feb 5, 2025
5342231
Address review feedback
jtraglia Feb 5, 2025
db84842
Fix double close
jtraglia Feb 5, 2025
1e5ff29
Use consistent log message
jtraglia Feb 5, 2025
357212a
Fix body close
jtraglia Feb 5, 2025
8e5b644
One request per relay
jtraglia Feb 6, 2025
5014440
Respond with client's favorite except
jtraglia Feb 6, 2025
6171a13
Add accept file
jtraglia Feb 6, 2025
a860bf2
Move content types to new file
jtraglia Feb 6, 2025
5761355
Default respond with JSON
jtraglia Feb 6, 2025
e7d51de
Fix nits
jtraglia Feb 6, 2025
25462d6
Add SupportsSSZ field to RelayEntry
jtraglia Feb 6, 2025
3f50e91
Improve media type handling
jtraglia Feb 6, 2025
0cde21a
Return NotAcceptable if the only accept type is unsupported
jtraglia Feb 6, 2025
4c9f49b
Fix lint
jtraglia Feb 6, 2025
7c27374
Remove now incorrect comment
jtraglia Feb 6, 2025
78463c4
Return JSON in getHeader under some circumstances
jtraglia Feb 11, 2025
17810c2
Include ethConsensusVersion in getHeader response
jtraglia Feb 11, 2025
461ceab
Decode header with ethConsensusVersion from response
jtraglia Feb 11, 2025
a405b92
Fix & improve tests
jtraglia Feb 11, 2025
f607ce3
Update comment
jtraglia Feb 13, 2025
160eb66
Merge branch 'develop' into ssz-get-header
jtraglia Feb 13, 2025
1ff636c
Do away with function map
jtraglia Feb 13, 2025
fd18b82
Pass header fields, not the whole header
jtraglia Feb 13, 2025
0e38a22
Add back HeaderKeySlotUID
jtraglia Feb 13, 2025
8cd3b0e
Use timewasted/go-accept-headers
jtraglia Feb 13, 2025
5b6caba
Clean up header fields
jtraglia Feb 14, 2025
3fbeafd
Fix some nits & add more debug prints
jtraglia Feb 14, 2025
dc7aaaa
Address review feedback
jtraglia Feb 14, 2025
fbfc3bd
Merge branch 'develop' into ssz-get-header
jtraglia Feb 14, 2025
dc70b85
Replace "client" with "proposer" in comments
jtraglia Feb 14, 2025
d3123bf
Remove unnecessary debug print
jtraglia Feb 14, 2025
744273a
Rename some variables & fix a nit
jtraglia Feb 14, 2025
5a919ff
Merge branch 'develop' into ssz-get-header
jtraglia Feb 14, 2025
de43986
Add another debug print
jtraglia Feb 14, 2025
3c85273
Use more header constants
jtraglia Feb 17, 2025
6f8b538
Rename media_type.go to constants.go & move header constants
jtraglia Feb 17, 2025
bdf4c6a
Fix nits
jtraglia Feb 17, 2025
93a4755
Add EthConsensusVersion constants
jtraglia Feb 17, 2025
14442d1
Fix linter
jtraglia Feb 17, 2025
1fcd5ff
Remove allBidsWereJSON logic
jtraglia Feb 17, 2025
a745a43
Fix lint
jtraglia Feb 17, 2025
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
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ linters:
- goconst
- gosec
- ireturn
- maintidx
- noctx
- tagliatelle
- perfsprint
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09
github.com/urfave/cli/v3 v3.0.0-beta1
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk=
github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 h1:QVxbx5l/0pzciWYOynixQMtUhPYC3YKD6EcUlOsgGqw=
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09/go.mod h1:Uy/Rnv5WKuOO+PuDhuYLEpUiiKIZtss3z519uk67aF0=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
Expand Down
176 changes: 163 additions & 13 deletions server/get_header.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ package server

import (
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"sync"
"time"

builderApiBellatrix "github.com/attestantio/go-builder-client/api/bellatrix"
builderApiCapella "github.com/attestantio/go-builder-client/api/capella"
builderApiDeneb "github.com/attestantio/go-builder-client/api/deneb"
builderApiElectra "github.com/attestantio/go-builder-client/api/electra"
builderSpec "github.com/attestantio/go-builder-client/spec"
"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/flashbots/mev-boost/config"
"github.com/flashbots/mev-boost/server/types"
Expand All @@ -16,7 +24,7 @@ import (
)

// getHeader requests a bid from each relay and returns the most profitable one
func (m *BoostService) getHeader(log *logrus.Entry, ua UserAgent, slot phase0.Slot, pubkey, parentHashHex string) (bidResp, error) {
func (m *BoostService) getHeader(log *logrus.Entry, slot phase0.Slot, pubkey, parentHashHex string, ua UserAgent, accept string) (bidResp, error) {
// Ensure arguments are valid
if len(pubkey) != 98 {
return bidResp{}, errInvalidPubkey
Expand All @@ -35,6 +43,10 @@ func (m *BoostService) getHeader(log *logrus.Entry, ua UserAgent, slot phase0.Sl
m.slotUIDLock.Unlock()
log = log.WithField("slotUID", slotUID)

// Compute these once, instead of for each relay
userAgent := wrapUserAgent(ua)
startTime := fmt.Sprintf("%d", time.Now().UTC().UnixMilli())

// Log how late into the slot the request starts
slotStartTimestamp := m.genesisTime + uint64(slot)*config.SlotTimeSec
msIntoSlot := uint64(time.Now().UTC().UnixMilli()) - slotStartTimestamp*1000
Expand All @@ -44,12 +56,6 @@ func (m *BoostService) getHeader(log *logrus.Entry, ua UserAgent, slot phase0.Sl
"msIntoSlot": msIntoSlot,
}).Infof("getHeader request start - %d milliseconds into slot %d", msIntoSlot, slot)

// Add request headers
headers := map[string]string{
HeaderKeySlotUID: slotUID.String(),
HeaderStartTimeUnixMS: fmt.Sprintf("%d", time.Now().UTC().UnixMilli()),
}

var (
mu sync.Mutex
wg sync.WaitGroup
Expand All @@ -71,20 +77,70 @@ func (m *BoostService) getHeader(log *logrus.Entry, ua UserAgent, slot phase0.Sl
url := relay.GetURI(fmt.Sprintf("/eth/v1/builder/header/%d/%s/%s", slot, parentHashHex, pubkey))
log := log.WithField("url", url)

// Send the get bid request to the relay
bid := new(builderSpec.VersionedSignedBuilderBid)
code, err := SendHTTPRequest(context.Background(), m.httpClientGetHeader, http.MethodGet, url, ua, headers, nil, bid)
// Make a new request
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
log.WithError(err).Warn("error making request to relay")
log.WithError(err).Warn("error creating new request")
return
}
if code == http.StatusNoContent {

// Add header fields to this request
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", accept)
req.Header.Set(HeaderStartTimeUnixMS, startTime)
req.Header.Set(HeaderKeySlotUID, slotUID.String())

// Send the request
log.Debug("requesting header")
resp, err := m.httpClientGetHeader.Do(req)
if err != nil {
log.WithError(err).Warn("error calling getHeader on relay")
return
}
defer resp.Body.Close()

// Check if no header is available
if resp.StatusCode == http.StatusNoContent {
log.Debug("no-content response")
return
}

// Check that the response was successful
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("%w: %d", errHTTPErrorResponse, resp.StatusCode)
log.WithError(err).Warn("error status code")
return
}

// Get the resp body content
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.WithError(err).Warn("error reading response body")
return
}

// Get the response's content type, default to JSON
respContentType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
respContentType = MediaTypeJSON
}
log = log.WithField("respContentType", respContentType)

// Get the optional version, used with SSZ decoding
respEthConsensusVersion := resp.Header.Get("Eth-Consensus-Version")
log = log.WithField("respEthConsensusVersion", respEthConsensusVersion)

// Decode bid
bid := new(builderSpec.VersionedSignedBuilderBid)
err = decodeBid(respBytes, respContentType, respEthConsensusVersion, bid)
if err != nil {
log.WithError(err).Warn("error decoding bid")
return
}

// Skip if bid is empty
if bid.IsEmpty() {
log.Debug("skipping empty bid")
return
}

Expand Down Expand Up @@ -157,20 +213,28 @@ func (m *BoostService) getHeader(log *logrus.Entry, ua UserAgent, slot phase0.Sl
mu.Lock()
defer mu.Unlock()

// Create a copy of the relay instance with its encoding preference. If we request SSZ and the relay
// responds with JSON, we know that it does not support SSZ yet. This preference will be used in getPayload,
// because we must encode the blinded block in the request in such a way that the relay can decode it.
relayWithEncodingPreference := relay.Copy()
relayWithEncodingPreference.SupportsSSZ = respContentType == MediaTypeOctetStream

// Remember which relays delivered which bids (multiple relays might deliver the top bid)
relays[BlockHashHex(bidInfo.blockHash.String())] = append(relays[BlockHashHex(bidInfo.blockHash.String())], relay)
relays[BlockHashHex(bidInfo.blockHash.String())] = append(relays[BlockHashHex(bidInfo.blockHash.String())], relayWithEncodingPreference)

// Compare the bid with already known top bid (if any)
if !result.response.IsEmpty() {
valueDiff := bidInfo.value.Cmp(result.bidInfo.value)
if valueDiff == -1 {
// The current bid is less profitable than already known one
log.Debug("ignoring less profitable bid")
return
} else if valueDiff == 0 {
// The current bid is equally profitable as already known one
// Use hash as tiebreaker
previousBidBlockHash := result.bidInfo.blockHash
if bidInfo.blockHash.String() >= previousBidBlockHash.String() {
log.Debug("equally profitable bid lost tiebreaker")
return
}
}
Expand All @@ -189,3 +253,89 @@ func (m *BoostService) getHeader(log *logrus.Entry, ua UserAgent, slot phase0.Sl
result.relays = relays[BlockHashHex(result.bidInfo.blockHash.String())]
return result, nil
}

// decodeBid decodes a bid by SSZ or JSON, depending on the provided respContentType
func decodeBid(respBytes []byte, respContentType, ethConsensusVersion string, bid *builderSpec.VersionedSignedBuilderBid) error {
switch respContentType {
case MediaTypeOctetStream:
if ethConsensusVersion != "" {
// Do SSZ decoding
switch ethConsensusVersion {
case "bellatrix":
bid.Version = spec.DataVersionBellatrix
bid.Bellatrix = new(builderApiBellatrix.SignedBuilderBid)
return bid.Bellatrix.UnmarshalSSZ(respBytes)
case "capella":
bid.Version = spec.DataVersionCapella
bid.Capella = new(builderApiCapella.SignedBuilderBid)
return bid.Capella.UnmarshalSSZ(respBytes)
case "deneb":
bid.Version = spec.DataVersionDeneb
bid.Deneb = new(builderApiDeneb.SignedBuilderBid)
return bid.Deneb.UnmarshalSSZ(respBytes)
case "electra":
bid.Version = spec.DataVersionElectra
bid.Electra = new(builderApiElectra.SignedBuilderBid)
return bid.Electra.UnmarshalSSZ(respBytes)
default:
return errInvalidForkVersion
}
} else {
return types.ErrMissingEthConsensusVersion
}
case MediaTypeJSON:
// Do JSON decoding
return json.Unmarshal(respBytes, bid)
}
return types.ErrInvalidContentType
}

// respondGetHeaderJSON responds to the client in JSON
func (m *BoostService) respondGetHeaderJSON(w http.ResponseWriter, result *bidResp) {
w.Header().Set("Content-Type", MediaTypeJSON)
w.WriteHeader(http.StatusOK)

// Serialize and write the data
if err := json.NewEncoder(w).Encode(&result.response); err != nil {
m.log.WithField("response", result.response).WithError(err).Error("could not write OK response")
http.Error(w, "", http.StatusInternalServerError)
}
}

// respondGetHeaderSSZ responds to the client in SSZ
func (m *BoostService) respondGetHeaderSSZ(w http.ResponseWriter, result *bidResp) {
// Serialize the response
var err error
var sszData []byte
switch result.response.Version {
case spec.DataVersionBellatrix:
w.Header().Set("Eth-Consensus-Version", "bellatrix")
sszData, err = result.response.Bellatrix.MarshalSSZ()
case spec.DataVersionCapella:
w.Header().Set("Eth-Consensus-Version", "capella")
sszData, err = result.response.Capella.MarshalSSZ()
case spec.DataVersionDeneb:
w.Header().Set("Eth-Consensus-Version", "deneb")
sszData, err = result.response.Deneb.MarshalSSZ()
case spec.DataVersionElectra:
w.Header().Set("Eth-Consensus-Version", "electra")
sszData, err = result.response.Electra.MarshalSSZ()
case spec.DataVersionUnknown, spec.DataVersionPhase0, spec.DataVersionAltair:
err = errInvalidForkVersion
}
if err != nil {
m.log.WithError(err).Error("error serializing response as SSZ")
http.Error(w, "failed to serialize response", http.StatusInternalServerError)
return
}

// Write the header
w.Header().Set("Content-Type", MediaTypeOctetStream)
w.WriteHeader(http.StatusOK)

// Write SSZ data
if _, err := w.Write(sszData); err != nil {
m.log.WithError(err).Error("error writing SSZ response")
http.Error(w, "failed to write response", http.StatusInternalServerError)
}
}
6 changes: 6 additions & 0 deletions server/media_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package server

const (
MediaTypeJSON = "application/json"
MediaTypeOctetStream = "application/octet-stream"
)
53 changes: 44 additions & 9 deletions server/mock/mock_relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -69,6 +70,10 @@ type Relay struct {
// Server section
Server *httptest.Server
ResponseDelay time.Duration

// Force response encodings
ForceJSON bool
ForceSSZ bool
}

// NewRelay creates a mocked relay which implements the backend.BoostBackend interface
Expand Down Expand Up @@ -211,6 +216,7 @@ func (m *Relay) MakeGetHeaderResponse(value uint64, blockHash, parentHash, publi
ParentHash: HexToHash(parentHash),
WithdrawalsRoot: phase0.Root{},
BaseFeePerGas: uint256.NewInt(0),
ExtraData: make([]byte, 0),
},
BlobKZGCommitments: make([]deneb.KZGCommitment, 0),
Value: uint256.NewInt(value),
Expand Down Expand Up @@ -268,15 +274,11 @@ func (m *Relay) handleGetHeader(w http.ResponseWriter, req *http.Request) {
m.handlerOverrideGetHeader(w, req)
return
}
m.defaultHandleGetHeader(w)
m.defaultHandleGetHeader(w, req)
}

// defaultHandleGetHeader returns the default handler for handleGetHeader
func (m *Relay) defaultHandleGetHeader(w http.ResponseWriter) {
// By default, everything will be ok.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

func (m *Relay) defaultHandleGetHeader(w http.ResponseWriter, req *http.Request) {
// Build the default response.
response := m.MakeGetHeaderResponse(
12345,
Expand All @@ -289,9 +291,42 @@ func (m *Relay) defaultHandleGetHeader(w http.ResponseWriter) {
response = m.GetHeaderResponse
}

if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
respondJSON := func() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

respondSSZ := func() {
w.Header().Set("Eth-Consensus-Version", "deneb")
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
sszData, err := response.Deneb.MarshalSSZ()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, err = w.Write(sszData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

// We cannot use code in server, so this is a simplistic
// negotiation which should only be used in testing.
switch {
case m.ForceJSON:
respondJSON()
case m.ForceSSZ:
respondSSZ()
case strings.Contains(req.Header.Get("Accept"), "application/octet-stream"):
respondSSZ()
default:
respondJSON()
}
}

Expand Down
Loading
Loading