diff --git a/README.md b/README.md
index d831cdd6..7081fe74 100644
--- a/README.md
+++ b/README.md
@@ -317,6 +317,80 @@ Example for setting a minimum bid value of 0.06 ETH:
Optionally, the `-metrics` flag can be provided to expose a prometheus metrics server. The metrics server address/port can be changed with the `-metrics-addr` (e.g., `-metrics-addr localhost:9009`) flag.
+### Enabling timing games
+The **Timing Games** feature allows `mev-boost` to optimize block proposal by strategically timing `getHeader` requests to relays. Instead of sending a single request immediately, it can delay the initial request and send multiple follow-up requests to capture the latest, most valuable bids right before the proposal deadline.
+
+To enable timing games, you must provide a YAML configuration file using the `-config` flag:
+
+```bash
+./mev-boost -config config.yaml
+```
+**Notice:** This feature is strictly meant for advanced users and extra care should be taken when setting up timing game associated parameters
+
+#### 1. Global Timeouts
+These settings apply to all relays and define the hard boundaries for the `getHeader` operation.
+
+* **`timeout_get_header_ms`** (optional, default: 900ms)
+ * It is the maximum timeout in milliseconds for get_header requests to relays.
+
+* **`late_in_slot_time_ms`** (optional, default: 1000ms)
+ * It is a safety threshold in milliseconds that marks when in a slot we consider it "too late" to fetch headers from relays. If the request arrives after the threshold, it skips all relay requests and forces local block building.
+ ```
+
+#### 2. Per-Relay Configuration
+These parameters are configured individually for each relay in `config.yaml`.
+
+* **`enable_timing_games`** (optional, default: false)
+ * Enables the timing games logic for this specific relay. If `false`, standard behavior is used (single request, immediate execution).
+
+* **`target_first_request_ms`** (`uint64`)
+ * It is the target time in milliseconds into the slot when the first get_header request should be sent to a relay. It's only used when `enable_timing_games = true`
+
+* **`frequency_get_header_ms`** (`uint64`)
+ * The interval in milliseconds between subsequent requests to the same relay. After the first request is sent, `mev-boost` will keep sending new requests every `frequency_get_header_ms` until the global timeout budget is exhausted.
+
+#### 3. Visual Representation
+
+```mermaid
+sequenceDiagram
+ participant CL as Consensus Client
+ participant MB as Mev-Boost
+ participant R as Relay (Timing Games Enabled)
+
+ Note over CL, R: Slot Start (t=0)
+
+ CL->>MB: getHeader
+ activate MB
+
+ Note right of MB: Calculate maxTimeout
min(timeout_get_header_ms,
late_in_slot - ms_into_slot)
+
+ opt target_first_request_ms - ms_into_slot > 0
+ Note right of MB: Sleep until target_first_request_ms - ms_into_slot
+ end
+
+ par Request 1
+ MB->>R: GET /get_header (Request 1)
+ R-->>MB: Bid A (Value: 1.0 ETH)
+ and Request 2 (after Frequency interval)
+ Note right of MB: Sleep FrequencyMs
+ MB->>R: GET /get_header (Request 2)
+ R-->>MB: Bid B (Value: 1.1 ETH)
+ and Request 3 (after Frequency interval)
+ Note right of MB: Sleep FrequencyMs
+ MB->>R: GET /get_header (Request 3)
+ R-->>MB: Bid C (Value: 1.2 ETH)
+ end
+
+ Note right of MB: Timeout Reached or
Replies Finished
+
+ Note right of MB: 1. Select best from Relay:
Bid C (Latest Received)
+
+ Note right of MB: 2. Compare with other relays
(Highest Value Wins)
+
+ MB-->>CL: Return Winning Bid (Bid C)
+ deactivate MB
+```
+
---
# API
diff --git a/cli/config.go b/cli/config.go
new file mode 100644
index 00000000..e3ddc8cb
--- /dev/null
+++ b/cli/config.go
@@ -0,0 +1,173 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/flashbots/mev-boost/server/types"
+ "github.com/fsnotify/fsnotify"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/viper"
+ "gopkg.in/yaml.v3"
+)
+
+type RelayConfigYAML struct {
+ URL string `yaml:"url"`
+ EnableTimingGames bool `yaml:"enable_timing_games"`
+ TargetFirstRequestMs uint64 `yaml:"target_first_request_ms"`
+ FrequencyGetHeaderMs uint64 `yaml:"frequency_get_header_ms"`
+}
+
+// Config holds all configuration settings from the config file
+type Config struct {
+ TimeoutGetHeaderMs uint64 `yaml:"timeout_get_header_ms"`
+ LateInSlotTimeMs uint64 `yaml:"late_in_slot_time_ms"`
+ Relays []RelayConfigYAML `yaml:"relays"`
+}
+
+type ConfigResult struct {
+ RelayConfigs map[string]types.RelayConfig
+ TimeoutGetHeaderMs uint64
+ LateInSlotTimeMs uint64
+}
+
+// ConfigWatcher provides hot reloading of config files
+type ConfigWatcher struct {
+ v *viper.Viper
+ configPath string
+ cliRelays []types.RelayEntry
+ onConfigChange func(*ConfigResult)
+ log *logrus.Entry
+}
+
+// LoadConfigFile loads configurations from a YAML file
+func LoadConfigFile(configPath string) (*ConfigResult, error) {
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var config Config
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return nil, err
+ }
+ return parseConfig(config)
+}
+
+// NewConfigWatcher creates a new config file watcher
+func NewConfigWatcher(configPath string, cliRelays []types.RelayEntry, log *logrus.Entry) (*ConfigWatcher, error) {
+ v := viper.New()
+ absPath, err := filepath.Abs(configPath)
+ if err != nil {
+ return nil, err
+ }
+
+ v.SetConfigFile(absPath)
+ v.SetConfigType("yaml")
+
+ if err := v.ReadInConfig(); err != nil {
+ return nil, err
+ }
+
+ return &ConfigWatcher{
+ v: v,
+ configPath: absPath,
+ cliRelays: cliRelays,
+ log: log,
+ }, nil
+}
+
+// Watch starts watching the config file for changes
+func (cw *ConfigWatcher) Watch(onConfigChange func(*ConfigResult)) {
+ cw.onConfigChange = onConfigChange
+
+ cw.v.OnConfigChange(func(_ fsnotify.Event) {
+ cw.log.Info("config file changed, reloading...")
+
+ // explicitly read the file to get the latest content since viper
+ // may cache the old value and not read the file immediately.
+ data, err := os.ReadFile(cw.configPath)
+ if err != nil {
+ cw.log.WithError(err).Error("failed to read new config file, keeping old config")
+ return
+ }
+
+ var config Config
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ cw.log.WithError(err).Error("failed to unmarshal new config, keeping old config")
+ return
+ }
+ newConfig, err := parseConfig(config)
+ if err != nil {
+ cw.log.WithError(err).Error("failed to parse new config, keeping old config")
+ return
+ }
+
+ cw.log.Infof("successfully loaded new config with %d relays from config file", len(newConfig.RelayConfigs))
+
+ if cw.onConfigChange != nil {
+ cw.onConfigChange(newConfig)
+ }
+ })
+
+ cw.v.WatchConfig()
+}
+
+// MergeRelayConfigs merges relays passed via --relays with config file settings.
+// this allows the users to still use --relays if they dont want to provide a config file
+func MergeRelayConfigs(relays []types.RelayEntry, configMap map[string]types.RelayConfig) []types.RelayConfig {
+ configs := make([]types.RelayConfig, 0)
+ processedURLs := make(map[string]bool)
+
+ for _, entry := range relays {
+ urlStr := entry.String()
+ if config, exists := configMap[urlStr]; exists {
+ config.RelayEntry = entry
+ configs = append(configs, config)
+ } else {
+ configs = append(configs, types.NewRelayConfig(entry))
+ }
+ processedURLs[urlStr] = true
+ }
+
+ for urlStr, config := range configMap {
+ if !processedURLs[urlStr] {
+ configs = append(configs, config)
+ }
+ }
+ return configs
+}
+
+func parseConfig(config Config) (*ConfigResult, error) {
+ timeoutGetHeaderMs := config.TimeoutGetHeaderMs
+ if timeoutGetHeaderMs == 0 {
+ timeoutGetHeaderMs = 900
+ }
+
+ lateInSlotTimeMs := config.LateInSlotTimeMs
+ if lateInSlotTimeMs == 0 {
+ lateInSlotTimeMs = 1000
+ }
+
+ configMap := make(map[string]types.RelayConfig)
+ for _, relay := range config.Relays {
+ relayEntry, err := types.NewRelayEntry(strings.TrimSpace(relay.URL))
+ if err != nil {
+ return nil, err
+ }
+ relayConfig := types.RelayConfig{
+ RelayEntry: relayEntry,
+ EnableTimingGames: relay.EnableTimingGames,
+ TargetFirstRequestMs: relay.TargetFirstRequestMs,
+ FrequencyGetHeaderMs: relay.FrequencyGetHeaderMs,
+ }
+ configMap[relayEntry.String()] = relayConfig
+ }
+
+ return &ConfigResult{
+ RelayConfigs: configMap,
+ TimeoutGetHeaderMs: timeoutGetHeaderMs,
+ LateInSlotTimeMs: lateInSlotTimeMs,
+ }, nil
+}
diff --git a/cli/config_test.go b/cli/config_test.go
new file mode 100644
index 00000000..623ae95a
--- /dev/null
+++ b/cli/config_test.go
@@ -0,0 +1,427 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/flashbots/mev-boost/server/types"
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewConfigWatcher(t *testing.T) {
+ t.Run("valid config file", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ configYAML := `
+timeout_get_header_ms: 1000
+late_in_slot_time_ms: 1500
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay.example.com
+ enable_timing_games: true
+ target_first_request_ms: 200
+ frequency_get_header_ms: 100
+`
+ err := os.WriteFile(configPath, []byte(configYAML), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{}, log)
+ require.NoError(t, err)
+ require.NotNil(t, watcher)
+ require.Equal(t, configPath, watcher.configPath)
+ })
+
+ t.Run("invalid config file path", func(t *testing.T) {
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher("/wrong/path/config.yaml", []types.RelayEntry{}, log)
+ require.Error(t, err)
+ require.Nil(t, watcher)
+ })
+
+ t.Run("invalid yaml content", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ invalidYAML := `invalid: yaml: content: [`
+ err := os.WriteFile(configPath, []byte(invalidYAML), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{}, log)
+ require.Error(t, err)
+ require.Nil(t, watcher)
+ })
+}
+
+func TestConfigWatcherWatchLive(t *testing.T) {
+ t.Run("config change triggers callback", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ initialConfig := `
+timeout_get_header_ms: 900
+late_in_slot_time_ms: 1000
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay1.example.com
+ enable_timing_games: false
+`
+ err := os.WriteFile(configPath, []byte(initialConfig), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{}, log)
+ require.NoError(t, err)
+
+ var callbackCalled sync.WaitGroup
+ callbackCalled.Add(1)
+
+ var receivedConfig *ConfigResult
+ var callbackMutex sync.Mutex
+
+ watcher.Watch(func(newConfig *ConfigResult) {
+ callbackMutex.Lock()
+ receivedConfig = newConfig
+ callbackMutex.Unlock()
+ callbackCalled.Done()
+ })
+
+ time.Sleep(100 * time.Millisecond)
+
+ updatedConfig := `
+timeout_get_header_ms: 1200
+late_in_slot_time_ms: 1500
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay2.example.com
+ enable_timing_games: true
+ target_first_request_ms: 300
+ frequency_get_header_ms: 150
+`
+ err = os.WriteFile(configPath, []byte(updatedConfig), 0o644)
+ require.NoError(t, err)
+
+ time.Sleep(50 * time.Millisecond)
+
+ done := make(chan struct{})
+ go func() {
+ callbackCalled.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // callback was called
+ callbackMutex.Lock()
+ require.NotNil(t, receivedConfig)
+ require.Equal(t, uint64(1200), receivedConfig.TimeoutGetHeaderMs)
+ require.Equal(t, uint64(1500), receivedConfig.LateInSlotTimeMs)
+ require.Len(t, receivedConfig.RelayConfigs, 1)
+ callbackMutex.Unlock()
+ case <-time.After(2 * time.Second):
+ t.Fatal("callback was not called within timeout")
+ }
+ })
+
+ t.Run("invalid config change keeps old config", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ initialConfig := `
+timeout_get_header_ms: 900
+late_in_slot_time_ms: 1000
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay1.example.com
+ enable_timing_games: false
+`
+ err := os.WriteFile(configPath, []byte(initialConfig), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{}, log)
+ require.NoError(t, err)
+
+ callbackCallCount := 0
+ var callbackMutex sync.Mutex
+
+ watcher.Watch(func(_ *ConfigResult) {
+ callbackMutex.Lock()
+ callbackCallCount++
+ callbackMutex.Unlock()
+ })
+
+ time.Sleep(200 * time.Millisecond)
+
+ // writin invalid yaml which will cause the config watcher to fail
+ invalidConfig := `invalid: yaml: [`
+ err = os.WriteFile(configPath, []byte(invalidConfig), 0o644)
+ require.NoError(t, err)
+
+ time.Sleep(500 * time.Millisecond)
+
+ callbackMutex.Lock()
+ require.Equal(t, 0, callbackCallCount)
+ callbackMutex.Unlock()
+ })
+
+ t.Run("config with invalid relay keeps old config", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ initialConfig := `
+timeout_get_header_ms: 900
+late_in_slot_time_ms: 1000
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay1.example.com
+ enable_timing_games: false
+`
+ err := os.WriteFile(configPath, []byte(initialConfig), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{}, log)
+ require.NoError(t, err)
+
+ callbackCallCount := 0
+ var callbackMutex sync.Mutex
+
+ watcher.Watch(func(_ *ConfigResult) {
+ callbackMutex.Lock()
+ callbackCallCount++
+ callbackMutex.Unlock()
+ })
+
+ time.Sleep(100 * time.Millisecond)
+
+ invalidConfig := `
+timeout_get_header_ms: 1200
+late_in_slot_time_ms: 1500
+relays:
+ - url: https://invalid-relay-url.com
+ enable_timing_games: false
+`
+ err = os.WriteFile(configPath, []byte(invalidConfig), 0o644)
+ require.NoError(t, err)
+
+ time.Sleep(500 * time.Millisecond)
+
+ callbackMutex.Lock()
+ require.Equal(t, 0, callbackCallCount)
+ callbackMutex.Unlock()
+ })
+
+ t.Run("multiple config updates", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ initialConfig := `
+timeout_get_header_ms: 900
+late_in_slot_time_ms: 1000
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay1.example.com
+ enable_timing_games: false
+`
+ err := os.WriteFile(configPath, []byte(initialConfig), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{}, log)
+ require.NoError(t, err)
+
+ callbackCallCount := 0
+ var callbackMutex sync.Mutex
+ var receivedConfigs []*ConfigResult
+
+ watcher.Watch(func(newConfig *ConfigResult) {
+ callbackMutex.Lock()
+ callbackCallCount++
+ receivedConfigs = append(receivedConfigs, newConfig)
+ callbackMutex.Unlock()
+ })
+
+ time.Sleep(100 * time.Millisecond)
+
+ // first update
+ config1 := `
+timeout_get_header_ms: 1000
+late_in_slot_time_ms: 1100
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay1.example.com
+ enable_timing_games: true
+ target_first_request_ms: 200
+`
+ err = os.WriteFile(configPath, []byte(config1), 0o644)
+ require.NoError(t, err)
+ time.Sleep(400 * time.Millisecond)
+
+ // second update
+ config2 := `
+timeout_get_header_ms: 1200
+late_in_slot_time_ms: 1500
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay2.example.com
+ enable_timing_games: false
+`
+ err = os.WriteFile(configPath, []byte(config2), 0o644)
+ require.NoError(t, err)
+ time.Sleep(400 * time.Millisecond)
+
+ callbackMutex.Lock()
+ callbackCount := callbackCallCount
+ configsCount := len(receivedConfigs)
+
+ // at least 2 callbacks and 2 configs should be received
+ // due to updates in the config file
+ require.GreaterOrEqual(t, callbackCount, 2)
+ require.GreaterOrEqual(t, configsCount, 2)
+
+ liveConfig := receivedConfigs[configsCount-1]
+ require.Equal(t, uint64(1200), liveConfig.TimeoutGetHeaderMs)
+ require.Equal(t, uint64(1500), liveConfig.LateInSlotTimeMs)
+
+ callbackMutex.Unlock()
+ })
+
+ t.Run("empty relays in config file allows keep using cli relays", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ cliRelay, err := types.NewRelayEntry("https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@cli-relay.example.com")
+ require.NoError(t, err)
+
+ initialConfig := `
+timeout_get_header_ms: 900
+late_in_slot_time_ms: 1000
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay1.example.com
+ enable_timing_games: false
+`
+ err = os.WriteFile(configPath, []byte(initialConfig), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{cliRelay}, log)
+ require.NoError(t, err)
+
+ var callbackCalled sync.WaitGroup
+ callbackCalled.Add(1)
+
+ var receivedConfig *ConfigResult
+ var mergedConfigs []types.RelayConfig
+ var callbackMutex sync.Mutex
+
+ watcher.Watch(func(newConfig *ConfigResult) {
+ callbackMutex.Lock()
+ receivedConfig = newConfig
+ mergedConfigs = MergeRelayConfigs([]types.RelayEntry{cliRelay}, newConfig.RelayConfigs)
+ callbackMutex.Unlock()
+ callbackCalled.Done()
+ })
+
+ time.Sleep(100 * time.Millisecond)
+
+ // sp now we are updating the config and removing all the relays
+ emptyConfig := `
+timeout_get_header_ms: 1200
+late_in_slot_time_ms: 1500
+relays: []
+`
+ err = os.WriteFile(configPath, []byte(emptyConfig), 0o644)
+ require.NoError(t, err)
+
+ done := make(chan struct{})
+ go func() {
+ callbackCalled.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ callbackMutex.Lock()
+ require.NotNil(t, receivedConfig)
+ require.Equal(t, uint64(1200), receivedConfig.TimeoutGetHeaderMs)
+ require.Equal(t, uint64(1500), receivedConfig.LateInSlotTimeMs)
+ require.Empty(t, receivedConfig.RelayConfigs)
+ require.Len(t, mergedConfigs, 1)
+ callbackMutex.Unlock()
+ case <-time.After(2 * time.Second):
+ t.Fatal("callback was not called within timeout")
+ }
+ })
+
+ t.Run("merges CLI relays with config file relays", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+
+ cliRelay, err := types.NewRelayEntry("https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@cli-relay.example.com")
+ require.NoError(t, err)
+
+ configYAML := `
+timeout_get_header_ms: 1000
+late_in_slot_time_ms: 1500
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@config-relay.example.com
+ enable_timing_games: true
+ target_first_request_ms: 200
+ frequency_get_header_ms: 100
+`
+ err = os.WriteFile(configPath, []byte(configYAML), 0o644)
+ require.NoError(t, err)
+
+ log := logrus.NewEntry(logrus.New())
+ watcher, err := NewConfigWatcher(configPath, []types.RelayEntry{cliRelay}, log)
+ require.NoError(t, err)
+
+ var receivedConfig *ConfigResult
+ var mergedConfigs []types.RelayConfig
+ var callbackMutex sync.Mutex
+ var callbackCalled sync.WaitGroup
+ callbackCalled.Add(1)
+
+ watcher.Watch(func(newConfig *ConfigResult) {
+ callbackMutex.Lock()
+ receivedConfig = newConfig
+ mergedConfigs = MergeRelayConfigs([]types.RelayEntry{cliRelay}, newConfig.RelayConfigs)
+ callbackMutex.Unlock()
+ callbackCalled.Done()
+ })
+
+ time.Sleep(100 * time.Millisecond)
+
+ // update config to add another relay
+ updatedConfig := `
+timeout_get_header_ms: 1200
+late_in_slot_time_ms: 1600
+relays:
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@config-relay.example.com
+ enable_timing_games: true
+ target_first_request_ms: 300
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@new-relay.example.com
+ enable_timing_games: false
+`
+ err = os.WriteFile(configPath, []byte(updatedConfig), 0o644)
+ require.NoError(t, err)
+
+ done := make(chan struct{})
+ go func() {
+ callbackCalled.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ callbackMutex.Lock()
+ require.NotNil(t, receivedConfig)
+ require.Equal(t, uint64(1200), receivedConfig.TimeoutGetHeaderMs)
+ require.Equal(t, uint64(1600), receivedConfig.LateInSlotTimeMs)
+ require.Len(t, receivedConfig.RelayConfigs, 2)
+ require.Len(t, mergedConfigs, 3)
+ callbackMutex.Unlock()
+ case <-time.After(2 * time.Second):
+ t.Fatal("callback was not called within timeout")
+ }
+ })
+}
diff --git a/cli/flags.go b/cli/flags.go
index f147c12b..47c9ae6a 100644
--- a/cli/flags.go
+++ b/cli/flags.go
@@ -29,6 +29,7 @@ var flags = []cli.Flag{
hoodiFlag,
// relay
relaysFlag,
+ relayConfigFlag,
deprecatedRelayMonitorFlag,
minBidFlag,
relayCheckFlag,
@@ -135,6 +136,12 @@ var (
Usage: "relay urls - single entry or comma-separated list (scheme://pubkey@host)",
Category: RelayCategory,
}
+ relayConfigFlag = &cli.StringFlag{
+ Name: "config",
+ Sources: cli.EnvVars("CONFIG_FILE"),
+ Usage: "path to YAML configuration file",
+ Category: RelayCategory,
+ }
deprecatedRelayMonitorFlag = &cli.StringSliceFlag{
Name: "relay-monitors",
Aliases: []string{"relay-monitor"},
diff --git a/cli/main.go b/cli/main.go
index 3f016b72..82597427 100644
--- a/cli/main.go
+++ b/cli/main.go
@@ -13,6 +13,7 @@ import (
"github.com/flashbots/mev-boost/common"
"github.com/flashbots/mev-boost/config"
"github.com/flashbots/mev-boost/server"
+ serverTypes "github.com/flashbots/mev-boost/server/types"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v3"
)
@@ -29,6 +30,15 @@ const (
genesisTimeHoodi = 1742213400
)
+type RelaySetupResult struct {
+ RelayConfigs []serverTypes.RelayConfig
+ MinBid types.U256Str
+ RelayCheck bool
+ TimeoutGetHeaderMs uint64
+ LateInSlotTimeMs uint64
+ CLIRelays []serverTypes.RelayEntry // CLI-provided relays for hot-reload merging
+}
+
var (
// errors
errInvalidLoglevel = errors.New("invalid loglevel")
@@ -66,7 +76,7 @@ func start(_ context.Context, cmd *cli.Command) error {
var (
genesisForkVersion, genesisTime = setupGenesis(cmd)
- relays, minBid, relayCheck = setupRelays(cmd)
+ relaySetup = setupRelays(cmd)
listenAddr = cmd.String(addrFlag.Name)
metricsEnabled = cmd.Bool(metricsFlag.Name)
metricsAddr = cmd.String(metricsAddrFlag.Name)
@@ -75,26 +85,47 @@ func start(_ context.Context, cmd *cli.Command) error {
opts := server.BoostServiceOpts{
Log: log,
ListenAddr: listenAddr,
- Relays: relays,
+ RelayConfigs: relaySetup.RelayConfigs,
GenesisForkVersionHex: genesisForkVersion,
GenesisTime: genesisTime,
- RelayCheck: relayCheck,
- RelayMinBid: minBid,
+ RelayCheck: relaySetup.RelayCheck,
+ RelayMinBid: relaySetup.MinBid,
RequestTimeoutGetHeader: time.Duration(cmd.Int(timeoutGetHeaderFlag.Name)) * time.Millisecond,
RequestTimeoutGetPayload: time.Duration(cmd.Int(timeoutGetPayloadFlag.Name)) * time.Millisecond,
RequestTimeoutRegVal: time.Duration(cmd.Int(timeoutRegValFlag.Name)) * time.Millisecond,
RequestMaxRetries: cmd.Int(maxRetriesFlag.Name),
MetricsAddr: metricsAddr,
+ TimeoutGetHeaderMs: relaySetup.TimeoutGetHeaderMs,
+ LateInSlotTimeMs: relaySetup.LateInSlotTimeMs,
}
service, err := server.NewBoostService(opts)
if err != nil {
log.WithError(err).Fatal("failed creating the server")
}
- if relayCheck && service.CheckRelays() == 0 {
+ if relaySetup.RelayCheck && service.CheckRelays() == 0 {
log.Error("no relay passed the health-check!")
}
+ // set up config file watcher if a config file is provided
+ if cmd.IsSet(relayConfigFlag.Name) {
+ configPath := cmd.String(relayConfigFlag.Name)
+ watcher, err := NewConfigWatcher(configPath, relaySetup.CLIRelays, log)
+ if err != nil {
+ log.WithError(err).Warn("failed to set up config watcher")
+ return err
+ }
+ // register a callback which gets invoked when config file changes
+ watcher.Watch(func(newConfig *ConfigResult) {
+ mergedConfigs := MergeRelayConfigs(relaySetup.CLIRelays, newConfig.RelayConfigs)
+ if len(mergedConfigs) == 0 {
+ log.Error("merged config has no relays (neither from CLI nor config file), keeping old config")
+ return
+ }
+ service.UpdateConfig(mergedConfigs, newConfig.TimeoutGetHeaderMs, newConfig.LateInSlotTimeMs)
+ })
+ }
+
if metricsEnabled {
go func() {
log.Infof("metrics server listening on %v", opts.MetricsAddr)
@@ -108,7 +139,7 @@ func start(_ context.Context, cmd *cli.Command) error {
return service.StartHTTPServer()
}
-func setupRelays(cmd *cli.Command) (relayList, types.U256Str, bool) {
+func setupRelays(cmd *cli.Command) RelaySetupResult {
// For backwards compatibility with the -relays flag.
var relays relayList
if cmd.IsSet(relaysFlag.Name) {
@@ -125,9 +156,32 @@ func setupRelays(cmd *cli.Command) (relayList, types.U256Str, bool) {
if len(relays) == 0 {
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())
+
+ // load configuration via config file
+ var configMap map[string]serverTypes.RelayConfig
+ var timeoutGetHeaderMs uint64 = 900
+ var lateInSlotTimeMs uint64 = 1000
+ if cmd.IsSet(relayConfigFlag.Name) {
+ configPath := cmd.String(relayConfigFlag.Name)
+ log.Infof("loading config from: %s", configPath)
+ configResult, err := LoadConfigFile(configPath)
+ if err != nil {
+ log.WithError(err).Fatal("failed to load config file")
+ } else {
+ configMap = configResult.RelayConfigs
+ timeoutGetHeaderMs = configResult.TimeoutGetHeaderMs
+ lateInSlotTimeMs = configResult.LateInSlotTimeMs
+ }
+ }
+ relayConfigs := MergeRelayConfigs(relays, configMap)
+
+ log.Infof("using %d relays", len(relayConfigs))
+ for index, config := range relayConfigs {
+ if config.EnableTimingGames {
+ log.Infof("relay #%d: %s timing games: enabled", index+1, config.RelayEntry.String())
+ } else {
+ log.Infof("relay #%d: %s", index+1, config.RelayEntry.String())
+ }
}
relayMinBidWei, err := sanitizeMinBid(cmd.Float(minBidFlag.Name))
@@ -137,7 +191,14 @@ func setupRelays(cmd *cli.Command) (relayList, types.U256Str, bool) {
if relayMinBidWei.BigInt().Sign() > 0 {
log.Infof("min bid set to %v eth (%v wei)", cmd.Float(minBidFlag.Name), relayMinBidWei)
}
- return relays, *relayMinBidWei, cmd.Bool(relayCheckFlag.Name)
+ return RelaySetupResult{
+ RelayConfigs: relayConfigs,
+ MinBid: *relayMinBidWei,
+ RelayCheck: cmd.Bool(relayCheckFlag.Name),
+ TimeoutGetHeaderMs: timeoutGetHeaderMs,
+ LateInSlotTimeMs: lateInSlotTimeMs,
+ CLIRelays: []serverTypes.RelayEntry(relays),
+ }
}
func setupGenesis(cmd *cli.Command) (string, uint64) {
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 00000000..a85a56eb
--- /dev/null
+++ b/config.example.yaml
@@ -0,0 +1,51 @@
+# Example configuration for mev-boost
+# This file can be passed to mev-boost using the -config flag: ./mev-boost -config config.yaml
+# the configuration supports hot-reloading, changes to this file will be automatically applied without restarts
+
+# Timeout in milliseconds for get_header requests to relays.
+# This value should be less then the timeout on the CL.
+# Default: 900ms
+timeout_get_header_ms: 900
+
+# Threshold in milliseconds that marks when in a slot is considered "too late".
+# If a getHeader request arrives after this threshold, mev-boost skips all relay requests
+# and forces local block building.
+# Default: 1000ms
+late_in_slot_time_ms: 1000
+
+# Relay Configurations
+# Each relay can be configured individually. Relays can also be provided via cli using
+# the -relay flag. Cli provided relays will be merged with config file relays.
+
+relays:
+ # Relay with timing games enabled
+ # Timing games allow mev-boost to send multiple requests at strategic intervals
+ # to capture the latest, most valuable bids right before the proposal deadline.
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay.relayer1.net
+ # Enable timing games strategy for this relay.
+ # When enabled, mev-boost will delay the first request and send multiple follow up requests.
+ # Default: false
+ enable_timing_games: true
+
+ # Target time in milliseconds when the first getHeader request should be sent.
+ # Only used when enable_timing_games is true.
+ # Example: 200 means wait until 200ms before sending the first request.
+ target_first_request_ms: 200
+
+ # Interval in milliseconds between subsequent getHeader requests to the same relay.
+ # After the first request, mev-boost will keep sending new requests every frequency_get_header_ms
+ # until the global timeout budget (maxTimeout) is exhausted.
+ # Only used when enable_timing_games is true.
+ # Example: 100 means send a new request every 100ms.
+ frequency_get_header_ms: 100
+
+ # Relay with timing games disabled (standard behavior)
+ # This relay will receive a single getHeader request immediately when the CL
+ # calls mev-boost, following the standard mev-boost behavior.
+ - url: https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@relay.relayer2.com
+ # Disable timing games - use standard single-request behavior
+ enable_timing_games: false
+
+ # These values are ignored when enable_timing_games is false
+ target_first_request_ms: 0
+ frequency_get_header_ms: 0
diff --git a/go.mod b/go.mod
index 87dec803..4af49579 100644
--- a/go.mod
+++ b/go.mod
@@ -7,12 +7,14 @@ require (
github.com/ethereum/go-ethereum v1.15.9
github.com/flashbots/go-boost-utils v1.10.0
github.com/flashbots/go-utils v0.10.0
+ github.com/fsnotify/fsnotify v1.9.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/holiman/uint256 v1.3.2
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/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09
github.com/urfave/cli/v3 v3.2.0
)
@@ -26,15 +28,25 @@ require (
github.com/emicklei/dot v1.8.0 // indirect
github.com/ethereum/c-kzg-4844 v1.0.3 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
github.com/supranational/blst v0.3.14 // indirect
github.com/valyala/fastrand v1.1.0 // indirect
github.com/valyala/histogram v1.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- golang.org/x/sync v0.13.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/sync v0.16.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)
@@ -61,5 +73,5 @@ require (
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index 4e229ccb..e56277b6 100644
--- a/go.sum
+++ b/go.sum
@@ -39,9 +39,15 @@ github.com/flashbots/go-boost-utils v1.10.0 h1:AGihhYtOjGF/efaBoQefYfmqzKsba6Y7S
github.com/flashbots/go-boost-utils v1.10.0/go.mod h1:vCtklzlENAGLqDrf6JteivgANjzXFqVSQQ3LtoQxyV8=
github.com/flashbots/go-utils v0.10.0 h1:75XWewRO5GIhdLn8+vqdzzuoqJh+j8wN54A++Id7W0Y=
github.com/flashbots/go-utils v0.10.0/go.mod h1:i4xxEB6sHDFfNWEIfh+rP6nx3LxynEn8AOZa05EYgwA=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
@@ -83,6 +89,8 @@ github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqky
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -96,14 +104,28 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo=
github.com/supranational/blst v0.3.14/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 h1:QVxbx5l/0pzciWYOynixQMtUhPYC3YKD6EcUlOsgGqw=
@@ -128,15 +150,19 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
-golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
-golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/server/get_header.go b/server/get_header.go
index c70d768f..1df687ac 100644
--- a/server/get_header.go
+++ b/server/get_header.go
@@ -25,8 +25,20 @@ import (
"github.com/sirupsen/logrus"
)
+type relayBid struct {
+ bid *builderSpec.VersionedSignedBuilderBid
+ relay types.RelayEntry
+ contentType string
+}
+
+type bidResult struct {
+ bid *builderSpec.VersionedSignedBuilderBid
+ contentType string
+ timestamp time.Time
+}
+
// getHeader requests a bid from each relay and returns the most profitable one
-func (m *BoostService) getHeader(log *logrus.Entry, slot phase0.Slot, pubkey, parentHashHex string, ua UserAgent, proposerAcceptContentTypes string) (bidResp, error) {
+func (m *BoostService) getHeader(log *logrus.Entry, slot phase0.Slot, pubkey, parentHashHex string, ua UserAgent, proposerAcceptContentTypes string, userTimeout uint64) (bidResp, error) {
// Ensure arguments are valid
if len(pubkey) != 98 {
return bidResp{}, errInvalidPubkey
@@ -47,7 +59,6 @@ func (m *BoostService) getHeader(log *logrus.Entry, slot phase0.Slot, pubkey, pa
// 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
@@ -59,218 +70,384 @@ func (m *BoostService) getHeader(log *logrus.Entry, slot phase0.Slot, pubkey, pa
}).Infof("getHeader request start - %d milliseconds into slot %d", msIntoSlot, slot)
var (
- mu sync.Mutex
- wg sync.WaitGroup
+ mu sync.Mutex
+ wg sync.WaitGroup
+ relayBids []relayBid
+ maxTimeoutMs uint64
+ )
- // The final response, containing the highest bid (if any)
- result = bidResp{}
+ m.relayConfigsLock.RLock()
+ relayConfigs := m.relayConfigs
+ timeoutGetHeaderMs := m.timeoutGetHeaderMs
+ lateInSlotTimeMs := m.lateInSlotTimeMs
+ m.relayConfigsLock.RUnlock()
- // Relays that sent the bid for a specific blockHash
- relays = make(map[BlockHashHex][]types.RelayEntry)
- )
+ if timeoutGetHeaderMs < lateInSlotTimeMs-msIntoSlot {
+ maxTimeoutMs = timeoutGetHeaderMs
+ } else {
+ maxTimeoutMs = lateInSlotTimeMs - msIntoSlot
+ }
+
+ if maxTimeoutMs == 0 {
+ return bidResp{}, nil
+ }
+
+ if userTimeout > 0 {
+ if userTimeout < maxTimeoutMs {
+ maxTimeoutMs = userTimeout
+ }
+ }
// Request a bid from each relay
- for _, relay := range m.relays {
+ for _, relayConfig := range relayConfigs {
wg.Add(1)
- go func(relay types.RelayEntry) {
+ go func(relayConfig types.RelayConfig) {
+ relay := relayConfig.RelayEntry
defer wg.Done()
// Build the request URL
url := relay.GetURI(fmt.Sprintf("/eth/v1/builder/header/%d/%s/%s", slot, parentHashHex, pubkey))
log := log.WithField("url", url)
- // Make a new request
- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
- if err != nil {
- log.WithError(err).Warn("error creating new request")
- return
- }
+ var bid *builderSpec.VersionedSignedBuilderBid
+ var contentType string
- // Add header fields to this request
- req.Header.Set(HeaderAccept, proposerAcceptContentTypes)
- req.Header.Set(HeaderKeySlotUID, slotUID.String())
- req.Header.Set(HeaderUserAgent, userAgent)
- req.Header.Set(HeaderDateMilliseconds, startTime)
- req.Header.Set(HeaderTimeoutMs, strconv.FormatInt(m.httpClientGetHeader.Timeout.Milliseconds(), 10))
-
- // Send the request
- log.Debug("requesting header")
- start := time.Now()
- resp, err := m.httpClientGetHeader.Do(req)
- RecordRelayLatency(params.PathGetHeader, relay.String(), float64(time.Since(start).Microseconds()))
- if err != nil {
- log.WithError(err).Warn("error calling getHeader on relay")
- return
+ // check if timing games enabled
+ if relayConfig.EnableTimingGames {
+ bid, contentType = m.handleTimingGamesGetHeader(log, relayConfig, url, slotUID, userAgent, proposerAcceptContentTypes, msIntoSlot, maxTimeoutMs)
+ } else {
+ bid, contentType = m.sendGetHeaderRequest(log, relay, url, slotUID, userAgent, proposerAcceptContentTypes, maxTimeoutMs)
}
- defer resp.Body.Close()
- RecordRelayStatusCode(strconv.Itoa(resp.StatusCode), params.PathGetHeader, relay.String())
- // Check if no header is available
- if resp.StatusCode == http.StatusNoContent {
- log.Debug("no-content response")
- return
- }
+ if bid != nil {
+ bidInfo, err := parseBidInfo(bid)
+ if err == nil {
+ valueEth := weiBigIntToEthBigFloat(bidInfo.value.ToBig())
+ valueEthFloat64, _ := valueEth.Float64()
+ RecordBidValue(relay.URL.Hostname(), valueEthFloat64)
+ }
- // 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
+ mu.Lock()
+ relayBids = append(relayBids, relayBid{bid: bid, relay: relay, contentType: contentType})
+ mu.Unlock()
}
+ }(relayConfig)
+ }
+ wg.Wait()
- // Get the resp body content
- respBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- log.WithError(err).Warn("error reading response body")
- return
- }
+ var (
+ result = bidResp{}
+ relays = make(map[BlockHashHex][]types.RelayEntry)
+ )
- // Get the response's content type, default to JSON
- respContentType, _, err := mime.ParseMediaType(resp.Header.Get(HeaderContentType))
- if err != nil {
- log.WithError(err).Warn("error parsing response content type")
- respContentType = MediaTypeJSON
- }
- log = log.WithField("respContentType", respContentType)
+ // process the bids and select the one with the best value
+ for _, rb := range relayBids {
+ m.processBid(log, rb.relay, rb.bid, rb.contentType, parentHashHex, &result, relays, slot)
+ }
- // Get the optional version, used with SSZ decoding
- respEthConsensusVersion := resp.Header.Get(HeaderEthConsensusVersion)
- log = log.WithField("respEthConsensusVersion", respEthConsensusVersion)
+ // Set the winning relays before returning
+ result.relays = relays[BlockHashHex(result.bidInfo.blockHash.String())]
- // Decode bid
- bid := new(builderSpec.VersionedSignedBuilderBid)
- err = decodeBid(respBytes, respContentType, respEthConsensusVersion, bid)
- if err != nil {
- log.WithError(err).Warn("error decoding bid")
- return
- }
+ if len(result.relays) > 0 {
+ RecordWinningBidValue(result.bidInfo.value.Float64())
+ }
- // Skip if bid is empty
- if bid.IsEmpty() {
- log.Debug("skipping empty bid")
- return
- }
+ return result, nil
+}
- // Getting the bid info will check if there are missing fields in the response
- bidInfo, err := parseBidInfo(bid)
- if err != nil {
- log.WithError(err).Warn("error parsing bid info")
- return
- }
+// handleTimingGamesGetHeader implements timing games strategy for a relay
+// Returns the latest bid and its content type from multiple timed requests
+func (m *BoostService) handleTimingGamesGetHeader(
+ log *logrus.Entry,
+ relayConfig types.RelayConfig,
+ url string,
+ slotUID uuid.UUID,
+ userAgent string,
+ proposerAcceptContentTypes string,
+ msIntoSlot uint64,
+ timeoutLeftMs uint64,
+) (*builderSpec.VersionedSignedBuilderBid, string) {
+ relay := relayConfig.RelayEntry
+
+ // wait til target time is configured
+ if relayConfig.TargetFirstRequestMs > 0 {
+ delayMs := relayConfig.TargetFirstRequestMs - msIntoSlot
+ if delayMs > 0 {
+ log.WithFields(logrus.Fields{
+ "targetMs": relayConfig.TargetFirstRequestMs,
+ "msIntoSlot": msIntoSlot,
+ "delayMs": delayMs,
+ }).Debug("waiting to send header request via timing games")
+ timeoutLeftMs -= delayMs
+ time.Sleep(time.Duration(delayMs) * time.Millisecond)
+ }
+ }
- // Ignore bids with an empty block
- if bidInfo.blockHash == nilHash {
- log.Warn("relay responded with empty block hash")
- return
- }
+ // send multiple requests at frequency intervals
+ if relayConfig.FrequencyGetHeaderMs > 0 { //nolint:nestif
+ log.WithFields(logrus.Fields{
+ "frequencyMs": relayConfig.FrequencyGetHeaderMs,
+ "timeoutLeftMs": timeoutLeftMs,
+ }).Debug("sending multiple header requests via timing games")
+
+ var bidResults []bidResult
+ var mu sync.Mutex
+
+ // keep sending requests until time runs out
+ var wg sync.WaitGroup
+ for timeoutLeftMs > 0 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ bid, contentType := m.sendGetHeaderRequest(log, relay, url, slotUID, userAgent, proposerAcceptContentTypes, timeoutLeftMs)
+ if bid != nil {
+ mu.Lock()
+ bidResults = append(bidResults, bidResult{
+ bid: bid,
+ contentType: contentType,
+ timestamp: time.Now(),
+ })
+ mu.Unlock()
+ }
+ }()
- // Add some info about the bid to the logger
- valueEth := weiBigIntToEthBigFloat(bidInfo.value.ToBig())
- log = log.WithFields(logrus.Fields{
- "blockNumber": bidInfo.blockNumber,
- "blockHash": bidInfo.blockHash.String(),
- "txRoot": bidInfo.txRoot.String(),
- "value": valueEth.Text('f', 18),
- })
-
- // Ensure the bid uses the correct public key
- if relay.PublicKey.String() != bidInfo.pubkey.String() {
- log.Errorf("bid pubkey mismatch. expected: %s - got: %s", relay.PublicKey.String(), bidInfo.pubkey.String())
- return
+ if timeoutLeftMs > relayConfig.FrequencyGetHeaderMs {
+ timeoutLeftMs -= relayConfig.FrequencyGetHeaderMs
+ time.Sleep(time.Duration(relayConfig.FrequencyGetHeaderMs) * time.Millisecond)
+ } else {
+ break
}
-
- // Verify the relay signature in the relay response
- if !config.SkipRelaySignatureCheck {
- ok, err := checkRelaySignature(bid, m.builderSigningDomain, relay.PublicKey)
- if err != nil {
- log.WithError(err).Error("error verifying relay signature")
- return
- }
- if !ok {
- log.Error("failed to verify relay signature")
- return
+ }
+ wg.Wait()
+
+ // select only the bid which was most recently received
+ if len(bidResults) > 0 {
+ log.WithField("totalBids", len(bidResults)).Debug("received headers from relay via timing games")
+ var latestBid *builderSpec.VersionedSignedBuilderBid
+ var latestContentType string
+ var latestTime time.Time
+ for _, br := range bidResults {
+ if latestBid == nil || br.timestamp.After(latestTime) {
+ latestBid = br.bid
+ latestContentType = br.contentType
+ latestTime = br.timestamp
}
}
+ return latestBid, latestContentType
+ }
+ log.Warn("no headers received via timing games")
+ return nil, ""
+ }
- // Verify response coherence with proposer's input data
- if bidInfo.parentHash.String() != parentHashHex {
- log.WithFields(logrus.Fields{
- "originalParentHash": parentHashHex,
- "responseParentHash": bidInfo.parentHash.String(),
- }).Error("proposer and relay parent hashes are not the same")
- return
- }
+ // in the case if frequency is not set, send only one getHeader request
+ return m.sendGetHeaderRequest(log, relay, url, slotUID, userAgent, proposerAcceptContentTypes, timeoutLeftMs)
+}
- // Ignore bids with 0 value
- isZeroValue := bidInfo.value.IsZero()
- isEmptyListTxRoot := bidInfo.txRoot.String() == "0x7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"
- if isZeroValue || isEmptyListTxRoot {
- log.Warn("ignoring bid with 0 value")
- return
- }
+// sendGetHeaderRequest sends a single getHeader request to a relay and returns the bid and content type
+func (m *BoostService) sendGetHeaderRequest(
+ log *logrus.Entry,
+ relay types.RelayEntry,
+ url string,
+ slotUID uuid.UUID,
+ userAgent string,
+ proposerAcceptContentTypes string,
+ timeoutMs uint64,
+) (*builderSpec.VersionedSignedBuilderBid, string) {
+ // Make a new request
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+ if err != nil {
+ log.WithError(err).Warn("error creating new request")
+ return nil, ""
+ }
- log.Debug("bid received")
+ // Add header fields to this request
+ req.Header.Set(HeaderAccept, proposerAcceptContentTypes)
+ req.Header.Set(HeaderKeySlotUID, slotUID.String())
+ req.Header.Set(HeaderUserAgent, userAgent)
+ req.Header.Set(HeaderDateMilliseconds, fmt.Sprintf("%d", time.Now().UTC().UnixMilli()))
+ req.Header.Set(HeaderTimeoutMs, strconv.FormatUint(timeoutMs, 10))
- RecordRelayLastSlot(relay.String(), uint64(slot))
+ // Send the request
+ log.Debug("requesting header")
+ start := time.Now()
- valueEthFloat64, _ := valueEth.Float64()
- RecordBidValue(relay.String(), valueEthFloat64)
+ m.httpClientGetHeader.Timeout = time.Duration(timeoutMs) * time.Millisecond
+ resp, err := m.httpClientGetHeader.Do(req)
+ RecordRelayLatency(params.PathGetHeader, relay.URL.Hostname(), float64(time.Since(start).Microseconds()))
+ if err != nil {
+ log.WithError(err).Warn("error calling getHeader on relay")
+ return nil, ""
+ }
+ defer resp.Body.Close()
- // Skip if value is lower than the minimum bid
- if bidInfo.value.CmpBig(m.relayMinBid.BigInt()) == -1 {
- log.Debug("ignoring bid below min-bid value")
- IncrementBidBelowMinBid(relay.String())
- return
- }
+ RecordRelayStatusCode(strconv.Itoa(resp.StatusCode), params.PathGetHeader, relay.URL.Hostname())
- 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())], relayWithEncodingPreference)
-
- // Compare the bid with already known top bid (if any)
- if !result.response.IsEmpty() {
- valueDiff := bidInfo.value.Cmp(result.bidInfo.value)
- switch valueDiff {
- case -1:
- // The current bid is less profitable than already known one
- log.Debug("ignoring less profitable bid")
- return
- case 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
- }
- }
- }
+ // Check if no header is available
+ if resp.StatusCode == http.StatusNoContent {
+ log.Debug("no-content response")
+ return nil, ""
+ }
- // Use this relay's response as mev-boost response because it's most profitable
- log.Debug("new best bid")
- result.response = *bid
- result.bidInfo = bidInfo
+ // 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 nil, ""
+ }
- result.t = time.Now()
- }(relay)
+ // Get the resp body content
+ respBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.WithError(err).Warn("error reading response body")
+ return nil, ""
}
- wg.Wait()
- // Set the winning relays before returning
- result.relays = relays[BlockHashHex(result.bidInfo.blockHash.String())]
- if len(result.relays) > 0 {
- RecordWinningBidValue(result.bidInfo.value.Float64())
+ // Get the response's content type, default to JSON
+ respContentType, _, err := mime.ParseMediaType(resp.Header.Get(HeaderContentType))
+ if err != nil {
+ log.WithError(err).Warn("error parsing response content type")
+ respContentType = MediaTypeJSON
}
+ log = log.WithField("respContentType", respContentType)
- return result, nil
+ // Get the optional version, used with SSZ decoding
+ respEthConsensusVersion := resp.Header.Get(HeaderEthConsensusVersion)
+ 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 nil, ""
+ }
+
+ // Skip if bid is empty
+ if bid.IsEmpty() {
+ log.Debug("skipping empty bid")
+ return nil, ""
+ }
+
+ return bid, respContentType
+}
+
+// processBid validates and stores a bid if it's better than the current best
+func (m *BoostService) processBid(
+ log *logrus.Entry,
+ relay types.RelayEntry,
+ bid *builderSpec.VersionedSignedBuilderBid,
+ respContentType string,
+ parentHashHex string,
+ result *bidResp,
+ relays map[BlockHashHex][]types.RelayEntry,
+ slot phase0.Slot,
+) {
+ // Getting the bid info will check if there are missing fields in the response
+ bidInfo, err := parseBidInfo(bid)
+ if err != nil {
+ log.WithError(err).Warn("error parsing bid info")
+ return
+ }
+
+ // Ignore bids with an empty block
+ if bidInfo.blockHash == nilHash {
+ log.Warn("relay responded with empty block hash")
+ return
+ }
+
+ // Add some info about the bid to the logger
+ valueEth := weiBigIntToEthBigFloat(bidInfo.value.ToBig())
+ log = log.WithFields(logrus.Fields{
+ "blockNumber": bidInfo.blockNumber,
+ "blockHash": bidInfo.blockHash.String(),
+ "txRoot": bidInfo.txRoot.String(),
+ "value": valueEth.Text('f', 18),
+ })
+
+ // Ensure the bid uses the correct public key
+ if relay.PublicKey.String() != bidInfo.pubkey.String() {
+ log.Errorf("bid pubkey mismatch. expected: %s - got: %s", relay.PublicKey.String(), bidInfo.pubkey.String())
+ return
+ }
+
+ // Verify the relay signature in the relay response
+ if !config.SkipRelaySignatureCheck {
+ ok, err := checkRelaySignature(bid, m.builderSigningDomain, relay.PublicKey)
+ if err != nil {
+ log.WithError(err).Error("error verifying relay signature")
+ return
+ }
+ if !ok {
+ log.Error("failed to verify relay signature")
+ return
+ }
+ }
+
+ // Verify response coherence with proposer's input data
+ if bidInfo.parentHash.String() != parentHashHex {
+ log.WithFields(logrus.Fields{
+ "originalParentHash": parentHashHex,
+ "responseParentHash": bidInfo.parentHash.String(),
+ }).Error("proposer and relay parent hashes are not the same")
+ return
+ }
+
+ // Ignore bids with 0 value
+ isZeroValue := bidInfo.value.IsZero()
+ isEmptyListTxRoot := bidInfo.txRoot.String() == "0x7ffe241ea60187fdb0187bfa22de35d1f9bed7ab061d9401fd47e34a54fbede1"
+ if isZeroValue || isEmptyListTxRoot {
+ log.Warn("ignoring bid with 0 value")
+ return
+ }
+
+ log.Debug("bid received")
+
+ RecordRelayLastSlot(relay.URL.Hostname(), uint64(slot))
+
+ valueEthFloat64, _ := valueEth.Float64()
+ RecordBidValue(relay.URL.Hostname(), valueEthFloat64)
+
+ // Skip if value is lower than the minimum bid
+ if bidInfo.value.CmpBig(m.relayMinBid.BigInt()) == -1 {
+ log.Debug("ignoring bid below min-bid value")
+ IncrementBidBelowMinBid(relay.URL.Hostname())
+ return
+ }
+
+ // 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())], relayWithEncodingPreference)
+
+ // Compare the bid with already known top bid (if any)
+ if !result.response.IsEmpty() {
+ valueDiff := bidInfo.value.Cmp(result.bidInfo.value)
+ switch valueDiff {
+ case -1:
+ // The current bid is less profitable than already known one
+ log.Debug("ignoring less profitable bid")
+ return
+ case 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
+ }
+ }
+ }
+
+ // Use this relay's response as mev-boost response because it's most profitable
+ log.Debug("new best bid")
+ result.response = *bid
+ result.bidInfo = bidInfo
+
+ result.t = time.Now()
}
// decodeBid decodes a bid by SSZ or JSON, depending on the provided respContentType
diff --git a/server/get_payload.go b/server/get_payload.go
index 7ab33b43..41e0e63f 100644
--- a/server/get_payload.go
+++ b/server/get_payload.go
@@ -143,7 +143,11 @@ func (m *BoostService) innerGetPayload(log *logrus.Entry, signedBlindedBeaconBlo
}
// Prepare for requests
- resultCh := make(chan payloadResult, len(m.relays))
+ m.relayConfigsLock.RLock()
+ relayConfigs := m.relayConfigs
+ m.relayConfigsLock.RUnlock()
+
+ resultCh := make(chan payloadResult, len(relayConfigs))
var received atomic.Bool
go func() {
// Make sure we receive a response within the timeout
@@ -155,7 +159,7 @@ func (m *BoostService) innerGetPayload(log *logrus.Entry, signedBlindedBeaconBlo
requestCtx, requestCtxCancel := context.WithTimeout(context.Background(), m.httpClientGetPayload.Timeout)
defer requestCtxCancel()
- for _, relay := range m.relays {
+ for _, relayConfig := range m.relayConfigs {
go func(relay types.RelayEntry, versionToUse GetPayloadVersion) {
var url string
if versionToUse == GetPayloadV1 {
@@ -227,13 +231,13 @@ func (m *BoostService) innerGetPayload(log *logrus.Entry, signedBlindedBeaconBlo
innerLog.Debug("submitting signed blinded block")
start := time.Now()
resp, err := m.httpClientGetPayload.Do(req)
- RecordRelayLatency(endpoint, relay.String(), float64(time.Since(start).Microseconds()))
+ RecordRelayLatency(endpoint, relay.URL.Hostname(), float64(time.Since(start).Microseconds()))
if err != nil {
innerLog.WithError(err).Warnf("error calling getPayload%s on relay", versionToUse)
return nil, err
}
- RecordRelayStatusCode(strconv.Itoa(statusCode), endpoint, relay.String())
+ RecordRelayStatusCode(strconv.Itoa(statusCode), endpoint, relay.URL.Hostname())
// Check that the response was successful
// If the response status code doesn't match expected, read error body once
@@ -320,7 +324,7 @@ func (m *BoostService) innerGetPayload(log *logrus.Entry, signedBlindedBeaconBlo
} else {
log.Trace("discarding response, already received a correct response")
}
- }(relay, version)
+ }(relayConfig.RelayEntry, version)
}
// Wait for the first request to complete
diff --git a/server/mock/mock_relay.go b/server/mock/mock_relay.go
index a806bb52..3ce8b9d0 100644
--- a/server/mock/mock_relay.go
+++ b/server/mock/mock_relay.go
@@ -488,6 +488,13 @@ func (m *Relay) OverrideHandleRegisterValidator(method func(w http.ResponseWrite
m.handlerOverrideRegisterValidator = method
}
+func (m *Relay) OverrideHandleGetHeader(method func(w http.ResponseWriter, req *http.Request)) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.handlerOverrideGetHeader = method
+}
+
func (m *Relay) OverrideHandleGetPayload(method func(w http.ResponseWriter, req *http.Request)) {
m.mu.Lock()
defer m.mu.Unlock()
diff --git a/server/register_validator.go b/server/register_validator.go
index cb31fd10..12ec5805 100644
--- a/server/register_validator.go
+++ b/server/register_validator.go
@@ -14,16 +14,20 @@ import (
)
func (m *BoostService) registerValidator(log *logrus.Entry, regBytes []byte, header http.Header) error {
- respErrCh := make(chan error, len(m.relays))
+ m.relayConfigsLock.RLock()
+ relayConfigs := m.relayConfigs
+ m.relayConfigsLock.RUnlock()
+
+ respErrCh := make(chan error, len(relayConfigs))
log.WithFields(logrus.Fields{
"timeout": m.httpClientRegVal.Timeout,
- "numRelays": len(m.relays),
+ "numRelays": len(relayConfigs),
"regBytes": len(regBytes),
}).Info("calling registerValidator on relays")
// Forward request to each relay
- for _, relay := range m.relays {
+ for _, relayConfig := range relayConfigs {
go func(relay types.RelayEntry) {
// Get the URL for this relay
requestURL := relay.GetURI(params.PathRegisterValidator)
@@ -49,7 +53,7 @@ func (m *BoostService) registerValidator(log *logrus.Entry, regBytes []byte, hea
// Send the request
start := time.Now()
resp, err := m.httpClientRegVal.Do(req)
- RecordRelayLatency(params.PathRegisterValidator, relay.String(), float64(time.Since(start).Microseconds()))
+ RecordRelayLatency(params.PathRegisterValidator, relay.URL.Hostname(), float64(time.Since(start).Microseconds()))
if err != nil {
log.WithError(err).Warn("error calling registerValidator on relay")
respErrCh <- err
@@ -57,7 +61,7 @@ func (m *BoostService) registerValidator(log *logrus.Entry, regBytes []byte, hea
}
resp.Body.Close()
- RecordRelayStatusCode(strconv.Itoa(resp.StatusCode), params.PathRegisterValidator, relay.String())
+ RecordRelayStatusCode(strconv.Itoa(resp.StatusCode), params.PathRegisterValidator, relay.URL.Hostname())
// Check if response is successful
if resp.StatusCode == http.StatusOK {
log.Debug("relay accepted registrations")
@@ -68,11 +72,11 @@ func (m *BoostService) registerValidator(log *logrus.Entry, regBytes []byte, hea
}).Debug("received an error response from relay")
respErrCh <- fmt.Errorf("%w: %d", errHTTPErrorResponse, resp.StatusCode)
}
- }(relay)
+ }(relayConfig.RelayEntry)
}
// Return OK if any relay responds OK
- for range m.relays {
+ for range relayConfigs {
respErr := <-respErrCh
if respErr == nil {
// Goroutines are independent, so if there are a lot of configured
diff --git a/server/register_validator_test.go b/server/register_validator_test.go
index 381fea04..1ffaf5aa 100644
--- a/server/register_validator_test.go
+++ b/server/register_validator_test.go
@@ -23,7 +23,7 @@ func TestHandleRegisterValidator_EmptyList(t *testing.T) {
defer relay.Server.Close()
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -47,7 +47,7 @@ func TestHandleRegisterValidator_NotEmptyList(t *testing.T) {
defer relay.Server.Close()
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -87,7 +87,7 @@ func TestHandleRegisterValidator_InvalidJSON(t *testing.T) {
defer relay.Server.Close()
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -111,7 +111,7 @@ func TestHandleRegisterValidator_ValidSSZ(t *testing.T) {
defer relay.Server.Close()
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -153,7 +153,7 @@ func TestHandleRegisterValidator_InvalidSSZ(t *testing.T) {
defer relay.Server.Close()
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -183,7 +183,7 @@ func TestHandleRegisterValidator_MultipleRelaysOneSuccess(t *testing.T) {
defer relaySuccess.Server.Close()
m := &BoostService{
- relays: []types.RelayEntry{badRelay.RelayEntry, relaySuccess.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(badRelay.RelayEntry), types.NewRelayConfig(relaySuccess.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -218,7 +218,7 @@ func TestHandleRegisterValidator_AllFail(t *testing.T) {
})
m := &BoostService{
- relays: []types.RelayEntry{badRelay1.RelayEntry, badRelay2.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(badRelay1.RelayEntry), types.NewRelayConfig(badRelay2.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -244,7 +244,7 @@ func TestHandleRegisterValidator_RelayNetworkError(t *testing.T) {
relay.Server.Close() // simulate network error
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
@@ -272,7 +272,7 @@ func TestHandleRegisterValidator_HeaderPropagation(t *testing.T) {
})
m := &BoostService{
- relays: []types.RelayEntry{relay.RelayEntry},
+ relayConfigs: []types.RelayConfig{types.NewRelayConfig(relay.RelayEntry)},
httpClientRegVal: *http.DefaultClient,
log: logrus.NewEntry(logrus.New()),
}
diff --git a/server/service.go b/server/service.go
index 070ff1af..fd4e4a56 100644
--- a/server/service.go
+++ b/server/service.go
@@ -55,7 +55,7 @@ type slotUID struct {
type BoostServiceOpts struct {
Log *logrus.Entry
ListenAddr string
- Relays []types.RelayEntry
+ RelayConfigs []types.RelayConfig
GenesisForkVersionHex string
GenesisTime uint64
RelayCheck bool
@@ -66,18 +66,21 @@ type BoostServiceOpts struct {
RequestTimeoutRegVal time.Duration
RequestMaxRetries int
+ TimeoutGetHeaderMs uint64
+ LateInSlotTimeMs uint64
+
MetricsAddr string
}
// BoostService - the mev-boost service
type BoostService struct {
- listenAddr string
- relays []types.RelayEntry
- log *logrus.Entry
- srv *http.Server
- relayCheck bool
- relayMinBid types.U256Str
- genesisTime uint64
+ listenAddr string
+ relayConfigs []types.RelayConfig
+ log *logrus.Entry
+ srv *http.Server
+ relayCheck bool
+ relayMinBid types.U256Str
+ genesisTime uint64
builderSigningDomain phase0.Domain
httpClientGetHeader http.Client
@@ -85,18 +88,23 @@ type BoostService struct {
httpClientRegVal http.Client
requestMaxRetries int
+ timeoutGetHeaderMs uint64
+ lateInSlotTimeMs uint64
+
bids map[string]bidResp // keeping track of bids, to log the originating relay on withholding
bidsLock sync.Mutex
slotUID *slotUID
slotUIDLock sync.Mutex
+ relayConfigsLock sync.RWMutex
+
metricsAddr string
}
// NewBoostService created a new BoostService
func NewBoostService(opts BoostServiceOpts) (*BoostService, error) {
- if len(opts.Relays) == 0 {
+ if len(opts.RelayConfigs) == 0 {
return nil, errNoRelays
}
@@ -106,15 +114,15 @@ func NewBoostService(opts BoostServiceOpts) (*BoostService, error) {
}
return &BoostService{
- listenAddr: opts.ListenAddr,
- relays: opts.Relays,
- log: opts.Log,
- relayCheck: opts.RelayCheck,
- relayMinBid: opts.RelayMinBid,
- genesisTime: opts.GenesisTime,
- bids: make(map[string]bidResp),
- slotUID: &slotUID{},
- metricsAddr: opts.MetricsAddr,
+ listenAddr: opts.ListenAddr,
+ relayConfigs: opts.RelayConfigs,
+ log: opts.Log,
+ relayCheck: opts.RelayCheck,
+ relayMinBid: opts.RelayMinBid,
+ genesisTime: opts.GenesisTime,
+ bids: make(map[string]bidResp),
+ slotUID: &slotUID{},
+ metricsAddr: opts.MetricsAddr,
builderSigningDomain: builderSigningDomain,
httpClientGetHeader: http.Client{
@@ -129,7 +137,9 @@ func NewBoostService(opts BoostServiceOpts) (*BoostService, error) {
Timeout: opts.RequestTimeoutRegVal,
CheckRedirect: httpClientDisallowRedirects,
},
- requestMaxRetries: opts.RequestMaxRetries,
+ requestMaxRetries: opts.RequestMaxRetries,
+ timeoutGetHeaderMs: opts.TimeoutGetHeaderMs,
+ lateInSlotTimeMs: opts.LateInSlotTimeMs,
}, nil
}
@@ -281,6 +291,7 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request)
rawProposerAcceptContentTypes = req.Header.Get(HeaderAccept)
parsedProposerAcceptContentTypes = goacceptheaders.Parse(rawProposerAcceptContentTypes)
+ headerTimeoutString = req.Header.Get(HeaderTimeoutMs)
)
// Parse the slot
@@ -303,8 +314,17 @@ func (m *BoostService) handleGetHeader(w http.ResponseWriter, req *http.Request)
})
log.Debug("handling request")
+ if headerTimeoutString == "" {
+ headerTimeoutString = "0"
+ }
+
+ headerTimeout, err := strconv.ParseUint(headerTimeoutString, 10, 64)
+ if err != nil {
+ m.respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
// Query the relays for the header
- result, err := m.getHeader(log, slot, pubkey, parentHashHex, ua, rawProposerAcceptContentTypes)
+ result, err := m.getHeader(log, slot, pubkey, parentHashHex, ua, rawProposerAcceptContentTypes, headerTimeout)
if err != nil {
IncrementBeaconNodeStatus(strconv.Itoa(http.StatusBadRequest), params.PathGetHeader)
m.respondError(w, http.StatusBadRequest, err.Error())
@@ -488,7 +508,11 @@ func (m *BoostService) CheckRelays() int {
var wg sync.WaitGroup
var numSuccessRequestsToRelay uint32
- for _, r := range m.relays {
+ m.relayConfigsLock.RLock()
+ relayConfigs := m.relayConfigs
+ m.relayConfigsLock.RUnlock()
+
+ for _, relayConfig := range relayConfigs {
wg.Add(1)
go func(relay types.RelayEntry) {
@@ -499,12 +523,12 @@ func (m *BoostService) CheckRelays() int {
start := time.Now()
code, err := SendHTTPRequest(context.Background(), m.httpClientGetHeader, http.MethodGet, url, "", nil, nil, nil)
- RecordRelayLatency(params.PathStatus, relay.String(), float64(time.Since(start).Microseconds()))
+ RecordRelayLatency(params.PathStatus, relay.URL.Hostname(), float64(time.Since(start).Microseconds()))
if err != nil {
log.WithError(err).Error("relay status error - request failed")
return
}
- RecordRelayStatusCode(strconv.Itoa(code), params.PathStatus, relay.String())
+ RecordRelayStatusCode(strconv.Itoa(code), params.PathStatus, relay.URL.Hostname())
if code == http.StatusOK {
log.Debug("relay status OK")
} else {
@@ -514,10 +538,20 @@ func (m *BoostService) CheckRelays() int {
// Success: increase counter and cancel all pending requests to other relays
atomic.AddUint32(&numSuccessRequestsToRelay, 1)
- }(r)
+ }(relayConfig.RelayEntry)
}
// At the end, wait for every routine and return status according to relay's ones.
wg.Wait()
return int(numSuccessRequestsToRelay)
}
+
+// UpdateConfig updates the relay configs and timeout settings
+func (m *BoostService) UpdateConfig(relayConfigs []types.RelayConfig, timeoutGetHeaderMs, lateInSlotTimeMs uint64) {
+ m.relayConfigsLock.Lock()
+ defer m.relayConfigsLock.Unlock()
+
+ m.relayConfigs = relayConfigs
+ m.timeoutGetHeaderMs = timeoutGetHeaderMs
+ m.lateInSlotTimeMs = lateInSlotTimeMs
+}
diff --git a/server/service_test.go b/server/service_test.go
index 4194ae89..2107f023 100644
--- a/server/service_test.go
+++ b/server/service_test.go
@@ -55,17 +55,17 @@ func newTestBackend(t *testing.T, numRelays int, relayTimeout time.Duration) *te
relays: make([]*mock.Relay, numRelays),
}
- relayEntries := make([]types.RelayEntry, numRelays)
+ relayConfigs := make([]types.RelayConfig, numRelays)
for i := 0; i < numRelays; i++ {
// Create a mock relay
backend.relays[i] = mock.NewRelay(t)
- relayEntries[i] = backend.relays[i].RelayEntry
+ relayConfigs[i] = types.NewRelayConfig(backend.relays[i].RelayEntry)
}
opts := BoostServiceOpts{
Log: mock.TestLog,
ListenAddr: "localhost:12345",
- Relays: relayEntries,
+ RelayConfigs: relayConfigs,
GenesisForkVersionHex: "0x00000000",
RelayCheck: true,
RelayMinBid: types.IntToU256(12345),
@@ -73,6 +73,8 @@ func newTestBackend(t *testing.T, numRelays int, relayTimeout time.Duration) *te
RequestTimeoutGetPayload: relayTimeout,
RequestTimeoutRegVal: relayTimeout,
RequestMaxRetries: 5,
+ TimeoutGetHeaderMs: 900,
+ LateInSlotTimeMs: 1000,
}
service, err := NewBoostService(opts)
require.NoError(t, err)
@@ -124,7 +126,7 @@ func TestNewBoostServiceErrors(t *testing.T) {
_, err := NewBoostService(BoostServiceOpts{
Log: mock.TestLog,
ListenAddr: ":123",
- Relays: []types.RelayEntry{},
+ RelayConfigs: []types.RelayConfig{},
GenesisForkVersionHex: "0x00000000",
GenesisTime: 0,
RelayCheck: true,
@@ -502,7 +504,7 @@ func TestGetHeader(t *testing.T) {
// Simulate a different public key registered to mev-boost
pk := phase0.BLSPubKey{}
- backend.boost.relays[0].PublicKey = pk
+ backend.boost.relayConfigs[0].RelayEntry.PublicKey = pk
rr := backend.request(t, http.MethodGet, path, header, nil)
require.Equal(t, 1, backend.relays[0].GetRequestCount(path))
@@ -761,6 +763,297 @@ func TestGetHeaderBids(t *testing.T) {
})
}
+func TestGetHeaderTimingGames(t *testing.T) {
+ hash := mock.HexToHash("0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7")
+ pubkey := mock.HexToPubkey(
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249")
+ path := getHeaderPath(3, hash, pubkey)
+
+ t.Run("Relay with timing games sends multiple requests", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 1, time.Second)
+
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 50 // request every 50ms
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+
+ // should have received multiple requests due to timing games
+ requestCount := backend.relays[0].GetRequestCount(path)
+ require.Greater(t, requestCount, 1)
+ })
+
+ t.Run("Relay with timing games delays first request", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 1, time.Second)
+
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 100 // wait 100ms from slot start
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 0 // no multiple requests
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ // with no frequency, should only send one request
+ require.Equal(t, 1, backend.relays[0].GetRequestCount(path))
+ })
+
+ t.Run("Mix of timing games and normal relays", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 3, time.Second)
+
+ // timing games enabled for only first relay
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 30
+
+ // second relay: without timing games enabled
+ backend.relays[1].GetHeaderResponse = backend.relays[1].MakeGetHeaderResponse(
+ 12346,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ // third relay: without timing games enabled
+ backend.relays[2].GetHeaderResponse = backend.relays[2].MakeGetHeaderResponse(
+ 12347,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+
+ // relay1 should have received more then 1 request due to timing games
+ require.Greater(t, backend.relays[0].GetRequestCount(path), 1)
+ // relay 2 and 3 should have received exactly one request
+ require.Equal(t, 1, backend.relays[1].GetRequestCount(path))
+ require.Equal(t, 1, backend.relays[2].GetRequestCount(path))
+
+ 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(12347), value)
+ })
+
+ t.Run("Timing games relay with higher bid wins", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 2, time.Second)
+
+ // relay1: timing games with higher bid
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 40
+ backend.relays[0].GetHeaderResponse = backend.relays[0].MakeGetHeaderResponse(
+ 12350,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ // relay2: normal with lower bid
+ backend.relays[1].GetHeaderResponse = backend.relays[1].MakeGetHeaderResponse(
+ 12348,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+
+ // relay1's bid should win
+ 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(12350), value)
+ })
+
+ t.Run("Timing games with SSZ encoding", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderEthConsensusVersion, "deneb")
+ header.Set(HeaderAccept, MediaTypeOctetStream)
+
+ backend := newTestBackend(t, 1, time.Second)
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 50
+
+ backend.relays[0].ForceSSZ = true
+ backend.relays[0].GetHeaderResponse = backend.relays[0].MakeGetHeaderResponse(
+ 12345,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ require.Greater(t, backend.relays[0].GetRequestCount(path), 1)
+ require.Equal(t, MediaTypeOctetStream, rr.Header().Get(HeaderContentType))
+
+ bid := new(builderApiDeneb.SignedBuilderBid)
+ err := bid.UnmarshalSSZ(rr.Body.Bytes())
+ require.NoError(t, err)
+ })
+
+ t.Run("Timing games respects timeout budget", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 1, time.Second)
+
+ backend.boost.timeoutGetHeaderMs = 100
+ backend.boost.lateInSlotTimeMs = 1000
+
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 20
+
+ start := time.Now()
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ elapsed := time.Since(start)
+
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ require.LessOrEqual(t, elapsed.Milliseconds(), int64(300))
+
+ requestCount := backend.relays[0].GetRequestCount(path)
+ require.Greater(t, requestCount, 1)
+ require.Equal(t, 5, requestCount) // 100ms / 20ms = 5 requests
+ })
+
+ t.Run("Multiple timing games relays compete", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 2, time.Second)
+
+ // both relays use timing games
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 40
+ backend.relays[0].GetHeaderResponse = backend.relays[0].MakeGetHeaderResponse(
+ 12345,
+ "0xa18385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ backend.boost.relayConfigs[1].EnableTimingGames = true
+ backend.boost.relayConfigs[1].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[1].FrequencyGetHeaderMs = 35
+ backend.relays[1].GetHeaderResponse = backend.relays[1].MakeGetHeaderResponse(
+ 12345,
+ "0xa28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+
+ require.Greater(t, backend.relays[0].GetRequestCount(path), 1)
+ require.Greater(t, backend.relays[1].GetRequestCount(path), 1)
+
+ resp := new(builderSpec.VersionedSignedBuilderBid)
+ err := json.Unmarshal(rr.Body.Bytes(), resp)
+ require.NoError(t, err)
+ blockHash, err := resp.BlockHash()
+ require.NoError(t, err)
+ require.Equal(t, "0xa18385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7", blockHash.String())
+ })
+
+ t.Run("Higher bid received on later request wins", func(t *testing.T) {
+ header := make(http.Header)
+ header.Set(HeaderAccept, MediaTypeJSON)
+
+ backend := newTestBackend(t, 1, time.Second)
+
+ // enable timing games for relay1
+ backend.boost.relayConfigs[0].EnableTimingGames = true
+ backend.boost.relayConfigs[0].TargetFirstRequestMs = 0
+ backend.boost.relayConfigs[0].FrequencyGetHeaderMs = 50 // request every 50ms
+
+ requestCount := 0
+
+ backend.relays[0].OverrideHandleGetHeader(func(w http.ResponseWriter, _ *http.Request) {
+ requestCount++
+
+ var resp *builderSpec.VersionedSignedBuilderBid
+ switch requestCount {
+ case 1:
+ // first request: lower bid
+ resp = backend.relays[0].MakeGetHeaderResponse(
+ 12345,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+ case 2:
+ // second request: medium bid
+ resp = backend.relays[0].MakeGetHeaderResponse(
+ 12400,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+ default:
+ resp = backend.relays[0].MakeGetHeaderResponse(
+ 12500,
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0xe28385e7bd68df656cd0042b74b69c3104b5356ed1f20eb69f1f925df47a3ab7",
+ "0x8a1d7b8dd64e0aafe7ea7b6c95065c9364cf99d38470c12ee807d55f7de1529ad29ce2c422e0b65e3d5a05c02caca249",
+ spec.DataVersionDeneb,
+ )
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ })
+
+ rr := backend.request(t, http.MethodGet, path, header, nil)
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+
+ finalCount := backend.relays[0].GetRequestCount(path)
+ require.Greater(t, finalCount, 2)
+
+ // should select the highest bid from the later request
+ bidResp := new(builderSpec.VersionedSignedBuilderBid)
+ err := json.Unmarshal(rr.Body.Bytes(), bidResp)
+ require.NoError(t, err)
+ value, err := bidResp.Value()
+ require.NoError(t, err)
+ require.Equal(t, uint256.NewInt(12500), value)
+ })
+}
+
func TestGetPayload(t *testing.T) {
path := params.PathGetPayload
blockHash := mock.HexToHash("0x534809bd2b6832edff8d8ce4cb0e50068804fd1ef432c8362ad708a74fdc0e46")
@@ -1363,7 +1656,7 @@ func TestCheckRelays(t *testing.T) {
url, err := url.ParseRequestURI(backend.relays[0].Server.URL)
require.NoError(t, err)
- backend.boost.relays[0].URL = url
+ backend.boost.relayConfigs[0].RelayEntry.URL = url
numHealthyRelays := backend.boost.CheckRelays()
require.Equal(t, 0, numHealthyRelays)
})
diff --git a/server/types/relay_entry.go b/server/types/relay_entry.go
index ec97f09f..9321dbbe 100644
--- a/server/types/relay_entry.go
+++ b/server/types/relay_entry.go
@@ -15,6 +15,19 @@ type RelayEntry struct {
SupportsSSZ bool
}
+type RelayConfig struct {
+ RelayEntry RelayEntry
+ EnableTimingGames bool
+ TargetFirstRequestMs uint64
+ FrequencyGetHeaderMs uint64
+}
+
+func NewRelayConfig(entry RelayEntry) RelayConfig {
+ return RelayConfig{
+ RelayEntry: entry,
+ }
+}
+
func (r *RelayEntry) String() string {
return r.URL.String()
}