Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add getHeader SSZ support #734

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 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
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
40 changes: 40 additions & 0 deletions server/accept.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package server

import (
"sort"
"strconv"
"strings"
)

// AcceptEntry represents a parsed Accept header entry with q-weight.
type AcceptEntry struct {
MediaType string
QValue float64
}

// ParseAcceptHeader parses and sorts the Accept header by q-values.
func ParseAcceptHeader(header string) []AcceptEntry {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you need some test cases

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've incorporated these into this PR. But with two changes. If there is no accept header or the accept header isn't in the supported media types, we will default to JSON.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no accept header or the accept header isn't in the supported media types, we will default to JSON.

we do the same although would return 406 if client only accepts media types we don't support, it's handled here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yes. I like that idea. Will do the same.

rawAcceptValues := strings.Split(header, ",")
entries := make([]AcceptEntry, 0, len(rawAcceptValues))

for _, part := range rawAcceptValues {
mediaQ := strings.Split(strings.TrimSpace(part), ";")
mediaType := mediaQ[0]
qValue := 1.0 // Default q-value if not specified

if len(mediaQ) > 1 && strings.HasPrefix(mediaQ[1], "q=") {
if q, err := strconv.ParseFloat(strings.TrimPrefix(mediaQ[1], "q="), 64); err == nil {
qValue = q
}
}

entries = append(entries, AcceptEntry{MediaType: mediaType, QValue: qValue})
}

// Sort by q-value (highest first)
sort.Slice(entries, func(i, j int) bool {
return entries[i].QValue > entries[j].QValue
})

return entries
}
105 changes: 94 additions & 11 deletions server/get_header.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ package server

import (
"context"
"encoding/json"
"fmt"
"io"
"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 +23,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, header http.Header) (bidResp, error) {
// Ensure arguments are valid
if len(pubkey) != 98 {
return bidResp{}, errInvalidPubkey
Expand Down Expand Up @@ -44,11 +51,9 @@ 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()),
}
// Get the optional version, used with SSZ decoding
ethConsensusVersion := header.Get("Eth-Consensus-Version")
log = log.WithField("ethConsensusVersion", ethConsensusVersion)

var (
mu sync.Mutex
Expand All @@ -71,18 +76,60 @@ 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 headers from the request to this request.
// This includes Accept and Eth-Consensus-Version, if provided.
for key, values := range header {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my only worry here would be that relays don't properly handle q-weighted Accept headers, which is fine as long as they default to JSON in that case, if they throw an error it would be problematic though

req.Header[key] = values
}

// 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
respContentType := resp.Header.Get("Content-Type")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do all relays properly set this header, otherwise might be best to assume JSON if header is missing

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. Defaulting to JSON sounds like a good idea to me. Will make this change.

log = log.WithField("respContentType", respContentType)

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

// Skip if bid is empty
if bid.IsEmpty() {
return
Expand Down Expand Up @@ -189,3 +236,39 @@ 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
}
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"
)
32 changes: 23 additions & 9 deletions server/mock/mock_relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,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 @@ -259,15 +260,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 @@ -280,9 +277,26 @@ 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
if req.Header.Get("Accept") == "application/octet-stream" {
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
}
} else {
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
}
}
}

Expand Down
78 changes: 74 additions & 4 deletions server/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
eth2ApiV1Capella "github.com/attestantio/go-eth2-client/api/v1/capella"
eth2ApiV1Deneb "github.com/attestantio/go-eth2-client/api/v1/deneb"
eth2ApiV1Electra "github.com/attestantio/go-eth2-client/api/v1/electra"
"github.com/attestantio/go-eth2-client/spec"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/flashbots/go-boost-utils/ssz"
"github.com/flashbots/go-utils/httplogger"
Expand Down Expand Up @@ -299,29 +300,37 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request)
ua = UserAgent(req.Header.Get("User-Agent"))
)

// Parse the slot
slotValue, err := strconv.ParseUint(vars["slot"], 10, 64)
if err != nil {
m.respondError(w, http.StatusBadRequest, errInvalidSlot.Error())
return
}
slot := phase0.Slot(slotValue)

// Add relevant fields to the logger
log := m.log.WithFields(logrus.Fields{
"method": "getHeader",
"slot": slot,
"parentHash": parentHashHex,
"pubkey": pubkey,
"ua": ua,
})
log.Debug("getHeader")
log.Debug("handling request")

// Additional header fields
header := req.Header
header.Set("User-Agent", wrapUserAgent(ua))
header.Set(HeaderStartTimeUnixMS, fmt.Sprintf("%d", time.Now().UTC().UnixMilli()))

// Query the relays for the header
result, err := m.getHeader(log, ua, slot, pubkey, parentHashHex)
result, err := m.getHeader(log, slot, pubkey, parentHashHex, header)
if err != nil {
m.respondError(w, http.StatusBadRequest, err.Error())
return
}

// Bail if none of the relays returned a bid
if result.response.IsEmpty() {
log.Info("no bid received")
w.WriteHeader(http.StatusNoContent)
Expand All @@ -333,6 +342,10 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request)
m.bids[bidKey(slot, result.bidInfo.blockHash)] = result
m.bidsLock.Unlock()

// How should we respond to the client
clientAccepts := ParseAcceptHeader(req.Header.Get("Accept"))
log.Debug("clientAccepts", clientAccepts)

// Log result
valueEth := weiBigIntToEthBigFloat(result.bidInfo.value.ToBig())
log.WithFields(logrus.Fields{
Expand All @@ -343,8 +356,65 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request)
"relays": strings.Join(types.RelayEntriesToStrings(result.relays), ", "),
}).Info("best bid")

// Return the bid
m.respondOK(w, &result.response)
// A function which responds in the JSON encoding
respondJSON := func() {
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)
}
}

// A function which responds in the SSZ encoding
respondSSZ := func() {
w.Header().Set("Content-Type", MediaTypeOctetStream)
w.WriteHeader(http.StatusOK)

// Serialize the response
var sszData []byte
switch result.response.Version {
case spec.DataVersionBellatrix:
sszData, err = result.response.Bellatrix.MarshalSSZ()
case spec.DataVersionCapella:
sszData, err = result.response.Capella.MarshalSSZ()
case spec.DataVersionDeneb:
sszData, err = result.response.Deneb.MarshalSSZ()
case spec.DataVersionElectra:
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 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)
}
}

// Return the bid. We iterate over the client's acceptable
// media types in order of highest to lowest quality.
for _, accept := range clientAccepts {
switch accept.MediaType {
case MediaTypeJSON:
respondJSON()
return
case MediaTypeOctetStream:
respondSSZ()
return
}
}

// If the accept value is unknown, respond with JSON
respondJSON()
}

// respondPayload responds to the proposer with the payload
Expand Down
Loading
Loading