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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Choose a reason for hiding this comment

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

Thanks a lot for this documentation! πŸ™‚ Can we keep on mentioning "Enable timing games" here but below the "Notice:" sentence, share a link to the rest of the details (config, explanations, diagram etc.). The link could point to a new file docs/timing-games.md and the main README would look less bloated without the advanced feature details.

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)

Choose a reason for hiding this comment

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

How did we choose defaults 900 and 1000? And could we suggest a general practice to the users for picking different numbers?

* 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<br/>min(timeout_get_header_ms,<br/>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<br/>Replies Finished

Note right of MB: 1. Select best from Relay:<br/>Bid C (Latest Received)

Note right of MB: 2. Compare with other relays<br/>(Highest Value Wins)

MB-->>CL: Return Winning Bid (Bid C)
deactivate MB
```

---

# API
Expand Down
173 changes: 173 additions & 0 deletions cli/config.go
Original file line number Diff line number Diff line change
@@ -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)) {

Choose a reason for hiding this comment

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

We seem to introduce a new watcher along with the config file, which is very nice and is super useful when experimenting with the timing games logic quickly. But when enabled by default, this can cause unwanted side effects since the user might save a few times while making changes. I think I'd want to make some changes and then do a restart with the new values, in production. Maybe we could introduce a simple -watch-config flag to enable the behavior when it is more helpful to use?

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

neeed to clearly specify this in a documentaton on what the default value is. you can do it in the config.yaml

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we already have doc and default values in the config file. Do you propose some different value?

timeoutGetHeaderMs = 900
}

lateInSlotTimeMs := config.LateInSlotTimeMs
if lateInSlotTimeMs == 0 {
lateInSlotTimeMs = 1000
Copy link
Collaborator

Choose a reason for hiding this comment

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

same for this.

}

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
}
Loading
Loading