diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 959e65c1..a35c6e71 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,6 +7,7 @@ on: permissions: contents: write + packages: write jobs: docker-image: @@ -20,6 +21,10 @@ jobs: - name: Get tag version run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + - name: Get release date + run: | + echo "RELEASE_DATE=$(date --rfc-3339=date)" >> ${GITHUB_ENV} + - name: Print version run: | echo $RELEASE_VERSION @@ -27,14 +32,14 @@ jobs: - name: Extract metadata (tags, labels) for Docker images id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@f7b4ed12385588c3f9bc252f0a2b520d83b52d48 with: - images: flashbots/mev-boost + images: ghcr.io/${{ github.event.repository.full_name }} tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} type=sha - type=pep440,pattern={{version}} - type=pep440,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ !contains(env.RELEASE_VERSION, '-') }} - name: Set up QEMU uses: docker/setup-qemu-action@v2 @@ -42,11 +47,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Login to DockerHub - uses: docker/login-action@v2 + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v3 @@ -55,6 +61,8 @@ jobs: push: true build-args: | VERSION=${{ env.RELEASE_VERSION }} + VCS_REF=${{ github.event.ref }} + BUILD_DATE=${{ env.RELEASE_DATE }} platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -77,7 +85,7 @@ jobs: with: distribution: goreleaser version: latest - args: release --skip-publish --config .goreleaser-build.yaml --rm-dist + args: release --skip=publish --config .goreleaser-build.yaml --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload @@ -120,4 +128,4 @@ jobs: with: args: release --config .goreleaser-release.yaml env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5d69e5db..fcaea618 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,27 +5,52 @@ on: branches: - develop pull_request: + branches: + - develop jobs: test: - name: Test + name: Test on Go ${{ matrix.go-version }} runs-on: ubuntu-latest + + strategy: + matrix: + go-version: [1.20, 1.21, 1.22] + steps: + - name: Checkout sources + id: checkout + uses: actions/checkout@v3 + - name: Set up Go - uses: actions/setup-go@v3 + id: setup-go + uses: actions/setup-go@v4 with: - go-version: ^1.22 - id: go + go-version: ${{ matrix.go-version }} + + - name: Cache Go modules + id: cache + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ matrix.go-version }}- - - name: Checkout sources - uses: actions/checkout@v2 + - name: Install dependencies + id: install-dependencies + run: go mod download - name: Run unit tests and generate the coverage report - run: make test-coverage + id: test + run: go test -coverprofile=coverage-${{ matrix.go-version }}.out -json > TestResults-${{ matrix.go-version }}.json - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + id: upload-coverage + uses: codecov/codecov-action@v3 with: - files: ./coverage.out + files: coverage-${{ matrix.go-version }}.out verbose: false - flags: unittests + flags: unittests-${{ matrix.go-version }} diff --git a/Dockerfile b/Dockerfile index 32477fb6..6450c2db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ # syntax=docker/dockerfile:1 FROM golang:1.22 as builder ARG VERSION +ARG VCS_REF +ARG BUILD_DATE WORKDIR /build COPY go.mod ./ diff --git a/README.md b/README.md index 41111059..9f53bcef 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,10 @@ Usage of mev-boost: relay monitor urls - single entry or comma-separated list (scheme://host) -relays string relay urls - single entry or comma-separated list (scheme://pubkey@host) + -privileged-builder + a single privileged builder(relay pubkey), can be specified multiple times + -privileged-builders string + privileged builders(relay pubkey) - single entry or comma-separated list -request-timeout-getheader int timeout for getHeader requests to the relay [ms] (default 950) -request-timeout-getpayload int diff --git a/cli/main.go b/cli/main.go index cc16b1bb..fb258790 100644 --- a/cli/main.go +++ b/cli/main.go @@ -32,17 +32,18 @@ var ( errInvalidLoglevel = errors.New("invalid loglevel") // defaults - defaultLogJSON = os.Getenv("LOG_JSON") != "" - defaultLogLevel = common.GetEnv("LOG_LEVEL", "info") - defaultListenAddr = common.GetEnv("BOOST_LISTEN_ADDR", "localhost:18550") - defaultRelayCheck = os.Getenv("RELAY_STARTUP_CHECK") != "" - defaultRelayMinBidEth = common.GetEnvFloat64("MIN_BID_ETH", 0) - defaultDisableLogVersion = os.Getenv("DISABLE_LOG_VERSION") == "1" // disables adding the version to every log entry - defaultDebug = os.Getenv("DEBUG") != "" - defaultLogServiceTag = os.Getenv("LOG_SERVICE_TAG") - defaultRelays = os.Getenv("RELAYS") - defaultRelayMonitors = os.Getenv("RELAY_MONITORS") - defaultMaxRetries = common.GetEnvInt("REQUEST_MAX_RETRIES", 5) + defaultLogJSON = os.Getenv("LOG_JSON") != "" + defaultLogLevel = common.GetEnv("LOG_LEVEL", "info") + defaultListenAddr = common.GetEnv("BOOST_LISTEN_ADDR", "localhost:18550") + defaultRelayCheck = os.Getenv("RELAY_STARTUP_CHECK") != "" + defaultRelayMinBidEth = common.GetEnvFloat64("MIN_BID_ETH", 0) + defaultDisableLogVersion = os.Getenv("DISABLE_LOG_VERSION") == "1" // disables adding the version to every log entry + defaultDebug = os.Getenv("DEBUG") != "" + defaultLogServiceTag = os.Getenv("LOG_SERVICE_TAG") + defaultRelays = os.Getenv("RELAYS") + defaultRelayMonitors = os.Getenv("RELAY_MONITORS") + defaultMaxRetries = common.GetEnvInt("REQUEST_MAX_RETRIES", 5) + defaultPrivilegedBuilders = os.Getenv("PRIVILEGED_BUILDERS") defaultGenesisForkVersion = common.GetEnv("GENESIS_FORK_VERSION", "") defaultGenesisTime = common.GetEnvInt("GENESIS_TIMESTAMP", -1) @@ -55,8 +56,9 @@ var ( defaultTimeoutMsGetPayload = common.GetEnvInt("RELAY_TIMEOUT_MS_GETPAYLOAD", 4000) // timeout for getPayload requests defaultTimeoutMsRegisterValidator = common.GetEnvInt("RELAY_TIMEOUT_MS_REGVAL", 3000) // timeout for registerValidator requests - relays relayList - relayMonitors relayMonitorList + relays relayList + relayMonitors relayMonitorList + privilegedBuilders privilegedBuilderList // cli flags printVersion = flag.Bool("version", false, "only print version") @@ -66,11 +68,12 @@ var ( logService = flag.String("log-service", defaultLogServiceTag, "add a 'service=...' tag to all log messages") logNoVersion = flag.Bool("log-no-version", defaultDisableLogVersion, "disables adding the version to every log entry") - listenAddr = flag.String("addr", defaultListenAddr, "listen-address for mev-boost server") - relayURLs = flag.String("relays", defaultRelays, "relay urls - single entry or comma-separated list (scheme://pubkey@host)") - relayCheck = flag.Bool("relay-check", defaultRelayCheck, "check relay status on startup and on the status API call") - relayMinBidEth = flag.Float64("min-bid", defaultRelayMinBidEth, "minimum bid to accept from a relay [eth]") - relayMonitorURLs = flag.String("relay-monitors", defaultRelayMonitors, "relay monitor urls - single entry or comma-separated list (scheme://host)") + listenAddr = flag.String("addr", defaultListenAddr, "listen-address for mev-boost server") + relayURLs = flag.String("relays", defaultRelays, "relay urls - single entry or comma-separated list (scheme://pubkey@host)") + relayCheck = flag.Bool("relay-check", defaultRelayCheck, "check relay status on startup and on the status API call") + relayMinBidEth = flag.Float64("min-bid", defaultRelayMinBidEth, "minimum bid to accept from a relay [eth]") + relayMonitorURLs = flag.String("relay-monitors", defaultRelayMonitors, "relay monitor urls - single entry or comma-separated list (scheme://host)") + privilegedBuilderKeys = flag.String("privileged-builders", defaultPrivilegedBuilders, "single entry or comma-separated list of relay username (pubkey)") relayTimeoutMsGetHeader = flag.Int("request-timeout-getheader", defaultTimeoutMsGetHeader, "timeout for getHeader requests to the relay [ms]") relayTimeoutMsGetPayload = flag.Int("request-timeout-getpayload", defaultTimeoutMsGetPayload, "timeout for getPayload requests to the relay [ms]") @@ -95,6 +98,7 @@ func Main() { // process repeatable flags flag.Var(&relays, "relay", "a single relay, can be specified multiple times") flag.Var(&relayMonitors, "relay-monitor", "a single relay monitor, can be specified multiple times") + flag.Var(&privilegedBuilders, "privileged-builder", "a single privileged builder, can be specified multiple times") // parse flags and get started flag.Parse() @@ -149,13 +153,24 @@ func Main() { } } + // set relay priorities + if *privilegedBuilderKeys != "" { + for _, builderKey := range strings.Split(*privilegedBuilderKeys, ",") { + err := privilegedBuilders.Set(strings.TrimSpace(builderKey)) + if err != nil { + log.WithError(err).WithField("privilegedBuilder", builderKey).Fatal("Invalid privileged builder") + } + } + } + if len(relays) == 0 { flag.Usage() log.Fatal("no relays specified") } log.Infof("using %d relays", len(relays)) for index, relay := range relays { - log.Infof("relay #%d: %s", index+1, relay.String()) + isPrivileged := privilegedBuilders.Contains(relay.PublicKey) + log.Infof("relay #%d: %s, privileged %t", index+1, relay.String(), isPrivileged) } // For backwards compatibility with the -relay-monitors flag. @@ -188,6 +203,7 @@ func Main() { ListenAddr: *listenAddr, Relays: relays, RelayMonitors: relayMonitors, + PrivilegedBuilders: privilegedBuilders, GenesisForkVersionHex: genesisForkVersionHex, GenesisTime: genesisTime, RelayCheck: *relayCheck, diff --git a/cli/types.go b/cli/types.go index 28c99b39..1b6cde8b 100644 --- a/cli/types.go +++ b/cli/types.go @@ -1,10 +1,13 @@ package cli import ( + "bytes" "errors" "net/url" "strings" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/flashbots/go-boost-utils/utils" "github.com/flashbots/mev-boost/server/types" ) @@ -67,3 +70,34 @@ func (rm *relayMonitorList) Set(value string) error { *rm = append(*rm, relayMonitor) return nil } + +type privilegedBuilderList []phase0.BLSPubKey + +func (pb *privilegedBuilderList) String() string { + privilegedBuilders := []string{} + for _, privilegedBuilder := range *pb { + privilegedBuilders = append(privilegedBuilders, privilegedBuilder.String()) + } + return strings.Join(privilegedBuilders, ",") +} + +func (pb *privilegedBuilderList) Contains(privilegedBuilder phase0.BLSPubKey) bool { + for _, entry := range *pb { + if bytes.Equal(entry[:], privilegedBuilder[:]) { + return true + } + } + return false +} + +func (pb *privilegedBuilderList) Set(value string) error { + privilegedBuilder, err := utils.HexToPubkey(value) + if err != nil { + return err + } + if pb.Contains(privilegedBuilder) { + return errDuplicateEntry + } + *pb = append(*pb, privilegedBuilder) + return nil +} diff --git a/server/mock/mock_relay.go b/server/mock/mock_relay.go index abd04e28..787a1069 100644 --- a/server/mock/mock_relay.go +++ b/server/mock/mock_relay.go @@ -28,16 +28,6 @@ import ( "github.com/stretchr/testify/require" ) -const ( - mockRelaySecretKeyHex = "0x4e343a647c5a5c44d76c2c58b63f02cdf3a9a0ec40f102ebc26363b4b1b95033" -) - -var ( - skBytes, _ = hexutil.Decode(mockRelaySecretKeyHex) - mockRelaySecretKey, _ = bls.SecretKeyFromBytes(skBytes) - mockRelayPublicKey, _ = bls.PublicKeyFromSecretKey(mockRelaySecretKey) -) - // Relay is used to fake a relay's behavior. // You can override each of its handler by setting the instance's HandlerOverride_METHOD_TO_OVERRIDE to your own // handler. @@ -72,7 +62,11 @@ type Relay struct { // A secret key must be provided to sign default and custom response messages func NewRelay(t *testing.T) *Relay { t.Helper() - relay := &Relay{t: t, secretKey: mockRelaySecretKey, publicKey: mockRelayPublicKey, requestCount: make(map[string]int)} + + relay := &Relay{t: t, requestCount: make(map[string]int)} + + relay.secretKey, _, _ = bls.GenerateNewKeypair() + relay.publicKey, _ = bls.PublicKeyFromSecretKey(relay.secretKey) // Initialize server relay.Server = httptest.NewServer(relay.getRouter()) @@ -80,7 +74,7 @@ func NewRelay(t *testing.T) *Relay { // Create the RelayEntry with correct pubkey url, err := url.Parse(relay.Server.URL) require.NoError(t, err) - urlWithKey := fmt.Sprintf("%s://%s@%s", url.Scheme, hexutil.Encode(bls.PublicKeyToBytes(mockRelayPublicKey)), url.Host) + urlWithKey := fmt.Sprintf("%s://%s@%s", url.Scheme, hexutil.Encode(bls.PublicKeyToBytes(relay.publicKey)), url.Host) relay.RelayEntry, err = types.NewRelayEntry(urlWithKey) require.NoError(t, err) return relay @@ -224,8 +218,9 @@ func (m *Relay) defaultHandleGetHeader(w http.ResponseWriter) { 12345, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + m.RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) if m.GetHeaderResponse != nil { response = m.GetHeaderResponse diff --git a/server/service.go b/server/service.go index b7d14e3a..d5054119 100644 --- a/server/service.go +++ b/server/service.go @@ -60,6 +60,7 @@ type BoostServiceOpts struct { ListenAddr string Relays []types.RelayEntry RelayMonitors []*url.URL + PrivilegedBuilders []phase0.BLSPubKey GenesisForkVersionHex string GenesisTime uint64 RelayCheck bool @@ -73,14 +74,15 @@ type BoostServiceOpts struct { // BoostService - the mev-boost service type BoostService struct { - listenAddr string - relays []types.RelayEntry - relayMonitors []*url.URL - log *logrus.Entry - srv *http.Server - relayCheck bool - relayMinBid types.U256Str - genesisTime uint64 + listenAddr string + relays []types.RelayEntry + relayMonitors []*url.URL + privilegedBuilders []phase0.BLSPubKey + log *logrus.Entry + srv *http.Server + relayCheck bool + relayMinBid types.U256Str + genesisTime uint64 builderSigningDomain phase0.Domain httpClientGetHeader http.Client @@ -107,15 +109,16 @@ func NewBoostService(opts BoostServiceOpts) (*BoostService, error) { } return &BoostService{ - listenAddr: opts.ListenAddr, - relays: opts.Relays, - relayMonitors: opts.RelayMonitors, - log: opts.Log, - relayCheck: opts.RelayCheck, - relayMinBid: opts.RelayMinBid, - genesisTime: opts.GenesisTime, - bids: make(map[bidRespKey]bidResp), - slotUID: &slotUID{}, + listenAddr: opts.ListenAddr, + relays: opts.Relays, + relayMonitors: opts.RelayMonitors, + privilegedBuilders: opts.PrivilegedBuilders, + log: opts.Log, + relayCheck: opts.RelayCheck, + relayMinBid: opts.RelayMinBid, + genesisTime: opts.GenesisTime, + bids: make(map[bidRespKey]bidResp), + slotUID: &slotUID{}, builderSigningDomain: builderSigningDomain, httpClientGetHeader: http.Client{ @@ -290,7 +293,7 @@ func (m *BoostService) handleRegisterValidator(w http.ResponseWriter, req *http. } // handleGetHeader requests bids from the relays -func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request) { +func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request) { //nolint:maintidx vars := mux.Vars(req) slot := vars["slot"] parentHashHex := vars["parent_hash"] @@ -347,6 +350,7 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request) } // Prepare relay responses result := bidResp{} // the final response, containing the highest bid (if any) + resultPrivileged := bidResp{} // the final response, containing the highest bid (if any) for privileged relays relays := make(map[BlockHashHex][]types.RelayEntry) // relays that sent the bid for a specific blockHash // Call the relays var mu sync.Mutex @@ -442,35 +446,46 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request) // 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) - // Compare the bid with already known top bid (if any) - if !result.response.IsEmpty() { - valueDiff := bidInfo.value.Cmp(result.bidInfo.value) - if valueDiff == -1 { // current bid is less profitable than already known one - return - } else if valueDiff == 0 { // current bid is equally profitable as already known one. Use hash as tiebreaker - previousBidBlockHash := result.bidInfo.blockHash - if bidInfo.blockHash.String() >= previousBidBlockHash.String() { - return - } - } + if m.isPrivilegedRelay(relay.PublicKey) { + m.setBestBid(&resultPrivileged, bidInfo, responsePayload, log) + } else { + m.setBestBid(&result, bidInfo, responsePayload, log) } - - // Use this relay's response as mev-boost response because it's most profitable - log.Debug("new best bid") - result.response = *responsePayload - result.bidInfo = bidInfo - result.t = time.Now() }(relay) } // Wait for all requests to complete... wg.Wait() - if result.response.IsEmpty() { + if resultPrivileged.response.IsEmpty() && result.response.IsEmpty() { log.Info("no bid received") w.WriteHeader(http.StatusNoContent) return } + if !resultPrivileged.response.IsEmpty() { + // Log result privileged + valueEth := weiBigIntToEthBigFloat(resultPrivileged.bidInfo.value.ToBig()) + resultPrivileged.relays = relays[BlockHashHex(resultPrivileged.bidInfo.blockHash.String())] + log.WithFields(logrus.Fields{ + "blockHash": resultPrivileged.bidInfo.blockHash.String(), + "blockNumber": resultPrivileged.bidInfo.blockNumber, + "txRoot": resultPrivileged.bidInfo.txRoot.String(), + "value": valueEth.Text('f', 18), + "relays": strings.Join(types.RelayEntriesToStrings(resultPrivileged.relays), ", "), + "privileged": true, + }).Info("best privileged bid") + + // Remember the bid, for future logging in case of withholding + bidKey := bidRespKey{slot: _slot, blockHash: resultPrivileged.bidInfo.blockHash.String()} + m.bidsLock.Lock() + m.bids[bidKey] = resultPrivileged + m.bidsLock.Unlock() + + // Return the bid + m.respondOK(w, &resultPrivileged.response) + return + } + // Log result valueEth := weiBigIntToEthBigFloat(result.bidInfo.value.ToBig()) result.relays = relays[BlockHashHex(result.bidInfo.blockHash.String())] @@ -480,6 +495,7 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request) "txRoot": result.bidInfo.txRoot.String(), "value": valueEth.Text('f', 18), "relays": strings.Join(types.RelayEntriesToStrings(result.relays), ", "), + "privileged": false, }).Info("best bid") // Remember the bid, for future logging in case of withholding @@ -492,6 +508,161 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request) m.respondOK(w, &result.response) } +func (m *BoostService) setBestBid(result *bidResp, bidInfo bidInfo, responsePayload *builderSpec.VersionedSignedBuilderBid, log *logrus.Entry) { + // Compare the bid with already known top bid (if any) + if !result.response.IsEmpty() { + valueDiff := bidInfo.value.Cmp(result.bidInfo.value) + if valueDiff == -1 { // current bid is less profitable than already known one + return + } else if valueDiff == 0 { // current bid is equally profitable as already known one. Use hash as tiebreaker + previousBidBlockHash := result.bidInfo.blockHash + if bidInfo.blockHash.String() >= previousBidBlockHash.String() { + return + } + } + } + + // Use this relay's response as mev-boost response because it's most profitable + log.Debug("new best bid") + result.response = *responsePayload + result.bidInfo = bidInfo + result.t = time.Now() +} + +func (m *BoostService) processCapellaPayload(w http.ResponseWriter, req *http.Request, log *logrus.Entry, payload *eth2ApiV1Capella.SignedBlindedBeaconBlock, body []byte) { + if payload.Message == nil || payload.Message.Body == nil || payload.Message.Body.ExecutionPayloadHeader == nil { + log.WithField("body", string(body)).Error("missing parts of the request payload from the beacon-node") + m.respondError(w, http.StatusBadRequest, "missing parts of the payload") + return + } + + // Get the slotUID for this slot + slotUID := "" + m.slotUIDLock.Lock() + if m.slotUID.slot == uint64(payload.Message.Slot) { + slotUID = m.slotUID.uid.String() + } else { + log.Warnf("latest slotUID is for slot %d rather than payload slot %d", m.slotUID.slot, payload.Message.Slot) + } + m.slotUIDLock.Unlock() + + // Prepare logger + ua := UserAgent(req.Header.Get("User-Agent")) + log = log.WithFields(logrus.Fields{ + "ua": ua, + "slot": payload.Message.Slot, + "blockHash": payload.Message.Body.ExecutionPayloadHeader.BlockHash.String(), + "parentHash": payload.Message.Body.ExecutionPayloadHeader.ParentHash.String(), + "slotUID": slotUID, + }) + + // Log how late into the slot the request starts + slotStartTimestamp := m.genesisTime + uint64(payload.Message.Slot)*config.SlotTimeSec + msIntoSlot := uint64(time.Now().UTC().UnixMilli()) - slotStartTimestamp*1000 + log.WithFields(logrus.Fields{ + "genesisTime": m.genesisTime, + "slotTimeSec": config.SlotTimeSec, + "msIntoSlot": msIntoSlot, + }).Infof("submitBlindedBlock request start - %d milliseconds into slot %d", msIntoSlot, payload.Message.Slot) + + // Get the bid! + bidKey := bidRespKey{slot: uint64(payload.Message.Slot), blockHash: payload.Message.Body.ExecutionPayloadHeader.BlockHash.String()} + m.bidsLock.Lock() + originalBid := m.bids[bidKey] + m.bidsLock.Unlock() + if originalBid.response.IsEmpty() { + log.Error("no bid for this getPayload payload found. was getHeader called before?") + } else if len(originalBid.relays) == 0 { + log.Warn("bid found but no associated relays") + } + + // Add request headers + headers := map[string]string{HeaderKeySlotUID: slotUID} + + // Prepare for requests + var wg sync.WaitGroup + var mu sync.Mutex + result := new(builderApi.VersionedSubmitBlindedBlockResponse) + + // Prepare the request context, which will be cancelled after the first successful response from a relay + requestCtx, requestCtxCancel := context.WithCancel(context.Background()) + defer requestCtxCancel() + + for _, relay := range m.relays { + wg.Add(1) + go func(relay types.RelayEntry) { + defer wg.Done() + url := relay.GetURI(params.PathGetPayload) + log := log.WithField("url", url) + log.Debug("calling getPayload") + + responsePayload := new(builderApi.VersionedSubmitBlindedBlockResponse) + _, err := SendHTTPRequestWithRetries(requestCtx, m.httpClientGetPayload, http.MethodPost, url, ua, headers, payload, responsePayload, m.requestMaxRetries, log) + if err != nil { + if errors.Is(requestCtx.Err(), context.Canceled) { + log.Info("request was cancelled") // this is expected, if payload has already been received by another relay + } else { + log.WithError(err).Error("error making request to relay") + } + return + } + + if getPayloadResponseIsEmpty(responsePayload) { + log.Error("response with empty data!") + return + } + + // Ensure the response blockhash matches the request + if payload.Message.Body.ExecutionPayloadHeader.BlockHash != responsePayload.Capella.BlockHash { + log.WithFields(logrus.Fields{ + "responseBlockHash": responsePayload.Capella.BlockHash.String(), + }).Error("requestBlockHash does not equal responseBlockHash") + return + } + + // Ensure the response blockhash matches the response block + calculatedBlockHash, err := utils.ComputeBlockHash(&builderApi.VersionedExecutionPayload{ + Version: responsePayload.Version, + Capella: responsePayload.Capella, + }, nil) + if err != nil { + log.WithError(err).Error("could not calculate block hash") + } else if responsePayload.Capella.BlockHash != calculatedBlockHash { + log.WithFields(logrus.Fields{ + "calculatedBlockHash": calculatedBlockHash.String(), + "responseBlockHash": responsePayload.Capella.BlockHash.String(), + }).Error("responseBlockHash does not equal hash calculated from response block") + } + + // Lock before accessing the shared payload + mu.Lock() + defer mu.Unlock() + + if requestCtx.Err() != nil { // request has been cancelled (or deadline exceeded) + return + } + + // Received successful response. Now cancel other requests and return immediately + requestCtxCancel() + *result = *responsePayload + log.Info("received payload from relay") + }(relay) + } + + // Wait for all requests to complete... + wg.Wait() + + // If no payload has been received from relay, log loudly about withholding! + if result.Capella == nil || result.Capella.BlockHash == nilHash { + originRelays := types.RelayEntriesToStrings(originalBid.relays) + log.WithField("relaysWithBid", strings.Join(originRelays, ", ")).Error("no payload received from relay!") + m.respondError(w, http.StatusBadGateway, errNoSuccessfulRelayResponse.Error()) + return + } + + m.respondOK(w, result) +} + func (m *BoostService) processDenebPayload(w http.ResponseWriter, req *http.Request, log *logrus.Entry, blindedBlock *eth2ApiV1Deneb.SignedBlindedBeaconBlock) { // Get the currentSlotUID for this slot currentSlotUID := "" @@ -692,3 +863,12 @@ func (m *BoostService) CheckRelays() int { wg.Wait() return int(numSuccessRequestsToRelay) } + +func (m *BoostService) isPrivilegedRelay(pubkey phase0.BLSPubKey) bool { + for _, builder := range m.privilegedBuilders { + if bytes.Equal(builder[:], pubkey[:]) { + return true + } + } + return false +} diff --git a/server/service_test.go b/server/service_test.go index f78d777f..da4c5853 100644 --- a/server/service_test.go +++ b/server/service_test.go @@ -71,6 +71,17 @@ func newTestBackend(t *testing.T, numRelays int, relayTimeout time.Duration) *te return &backend } +func (be *testBackend) setPrivilegedBuilders(pubKey phase0.BLSPubKey) *testBackend { + privilegedBuilders := make([]phase0.BLSPubKey, 0) + for _, relay := range be.relays { + if bytes.Equal(relay.RelayEntry.PublicKey[:], pubKey[:]) { + privilegedBuilders = append(privilegedBuilders, relay.RelayEntry.PublicKey) + } + } + be.boost.privilegedBuilders = privilegedBuilders + return be +} + func (be *testBackend) request(t *testing.T, method, path string, payload any) *httptest.ResponseRecorder { t.Helper() var req *http.Request @@ -311,7 +322,7 @@ func TestGetHeader(t *testing.T) { 12345, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), spec.DataVersionDeneb, ) backend.relays[0].GetHeaderResponse = resp @@ -326,8 +337,9 @@ func TestGetHeader(t *testing.T) { 12345, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) resp.Deneb.Message.Header.BlockHash = nilHash @@ -353,8 +365,9 @@ func TestGetHeader(t *testing.T) { 12345, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // Simulate a different public key registered to mev-boost @@ -375,8 +388,9 @@ func TestGetHeader(t *testing.T) { 12345, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // Scramble the signature @@ -447,8 +461,9 @@ func TestGetHeaderBids(t *testing.T) { 12345, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // First relay will return signed response with value 12347. @@ -456,8 +471,9 @@ func TestGetHeaderBids(t *testing.T) { 12347, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[1].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // First relay will return signed response with value 12346. @@ -465,8 +481,9 @@ func TestGetHeaderBids(t *testing.T) { 12346, "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[2].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // Run the request. @@ -496,24 +513,27 @@ func TestGetHeaderBids(t *testing.T) { 12345, "0xa38385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) backend.relays[1].GetHeaderResponse = backend.relays[1].MakeGetHeaderResponse( 12345, "0xa18385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[1].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) backend.relays[2].GetHeaderResponse = backend.relays[2].MakeGetHeaderResponse( 12345, "0xa28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[2].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // Run the request. @@ -548,8 +568,9 @@ func TestGetHeaderBids(t *testing.T) { 12344, "0xa28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // Run the request. @@ -571,8 +592,9 @@ func TestGetHeaderBids(t *testing.T) { 12345, "0xa28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) // Run the request. @@ -589,6 +611,42 @@ func TestGetHeaderBids(t *testing.T) { require.NoError(t, err) require.Equal(t, uint256.NewInt(12345), value) }) + + t.Run("Use header from a privileged relay", func(t *testing.T) { + backend := newTestBackend(t, 2, time.Second) + backend.setPrivilegedBuilders(backend.relays[0].RelayEntry.PublicKey) + + // privileged relay + backend.relays[0].GetHeaderResponse = backend.relays[0].MakeGetHeaderResponse( + 12348, + "0xa28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", + "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, + ) + // non-privileged relay with higher value + backend.relays[1].GetHeaderResponse = backend.relays[1].MakeGetHeaderResponse( + 12349, + "0xa28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", + "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", + backend.relays[1].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, + ) + + rr := backend.request(t, http.MethodGet, path, nil) + + require.Equal(t, 1, backend.relays[0].GetRequestCount(path)) + require.Equal(t, 1, backend.relays[1].GetRequestCount(path)) + + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + // Highest value should be 12348, i.e. privileged relay. + resp := new(builderSpec.VersionedSignedBuilderBid) + err := json.Unmarshal(rr.Body.Bytes(), resp) + require.NoError(t, err) + value, err := resp.Value() + require.NoError(t, err) + require.Equal(t, uint256.NewInt(12348), value) + }) } func TestGetPayload(t *testing.T) { @@ -802,8 +860,9 @@ func TestGetPayloadToAllRelays(t *testing.T) { 12345, "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", - "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", - spec.DataVersionDeneb, + "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249", + backend.relays[0].RelayEntry.PublicKey.String(), + spec.DataVersionCapella, ) rr := backend.request(t, http.MethodGet, getHeaderPath, nil) require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())